Merge main into fix/secondary-shortcuts
Resolve the vi.tsv conflict (keep both sides), regenerate docs/scorecard.md, and translate the new "Add Shortcut" label in the Turkish catalog that landed on main (#3809), so every_tl_literal_is_translated stays green. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
d420ad668e
|
|
@ -20,6 +20,7 @@ next to them.
|
|||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
|
|
|
|||
|
|
@ -1743,6 +1743,7 @@ dependencies = [
|
|||
"miniz_oxide 0.8.9",
|
||||
"num-complex",
|
||||
"pulp",
|
||||
"rayon-core",
|
||||
"smallvec",
|
||||
"zune-inflate",
|
||||
]
|
||||
|
|
@ -2331,9 +2332,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|||
|
||||
[[package]]
|
||||
name = "heic-rs"
|
||||
version = "0.1.1"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
|
||||
checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -331,8 +331,6 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi
|
|||
| arm64 (Snapdragon and other ARM PCs) | `photocraft-<ver>-windows-arm64.msi` | `photocraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `photocraft-<ver>-windows-x86.msi` | `photocraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Windows 10 or later is required. On Windows 7 and 8 the app can't start (the installer says so).
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
### macOS
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
|
|||
let opts = export_opts(a)?;
|
||||
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &o.warnings);
|
||||
warn_all(err, &missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&o.document)));
|
||||
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document));
|
||||
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &ws);
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use std::time::Instant;
|
|||
|
||||
use photocraft_automation::budgets::write_reply;
|
||||
use photocraft_automation::security::{
|
||||
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line,
|
||||
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply,
|
||||
};
|
||||
use photocraft_ui_egui::ControlRequest;
|
||||
use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error};
|
||||
|
|
@ -79,6 +79,12 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
|||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(reply) = repeat_auth_reply(&line) {
|
||||
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
|
|
@ -202,4 +208,44 @@ mod tests {
|
|||
handler.join().unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_auth_reports_already_authenticated_without_dispatching() {
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
serve(stream, TOKEN, tx, egui::Context::default());
|
||||
});
|
||||
let handler = std::thread::spawn(move || {
|
||||
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(req.method, "test.after", "a repeat auth must not reach the UI dispatcher");
|
||||
req.reply.send(json!({"ok": true, "result": "still serving"})).unwrap();
|
||||
});
|
||||
let mut stream = TcpStream::connect(addr).unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut line = String::new();
|
||||
for id in ["auth", "again"] {
|
||||
writeln!(stream, "{}", json!({"id": id, "method": "auth", "params": {"token": TOKEN}})).unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
}
|
||||
let again: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(again["id"], "again");
|
||||
assert_eq!(again["ok"], false);
|
||||
assert_eq!(again["error"], "already authenticated");
|
||||
writeln!(stream, "{}", json!({"id": 9, "method": "test.after"})).unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let after: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(after["id"], 9);
|
||||
assert_eq!(after["result"], "still serving");
|
||||
drop(reader);
|
||||
drop(stream);
|
||||
handler.join().unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ mod recovery_tests;
|
|||
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
|
||||
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
|
||||
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
"svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
"pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm",
|
||||
"pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf",
|
||||
"abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
|
||||
|
|
@ -43,6 +43,7 @@ fn open_filters() -> Vec<(&'static str, Vec<String>)> {
|
|||
("All Formats", open_filter_extensions(OPEN_EXTS)),
|
||||
("PhotoCraft", open_filter_extensions(&["pcraft"])),
|
||||
("OpenRaster", open_filter_extensions(&["ora"])),
|
||||
("Krita", open_filter_extensions(&["kra"])),
|
||||
("SVG", open_filter_extensions(SVG_EXTS)),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.jonathanwxh-cell]
|
||||
display_name = "jonathnwxh"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Skyfuller1303]
|
||||
display_name = "Skyfuller"
|
||||
sync_github_name = true
|
||||
|
|
@ -117,3 +121,6 @@ sync_github_name = true
|
|||
|
||||
[people.FredApps]
|
||||
sync_github_name = true
|
||||
|
||||
[people.arthurdeka]
|
||||
sync_github_name = true
|
||||
|
|
|
|||
|
|
@ -59,6 +59,22 @@ fn main() {
|
|||
prev = p;
|
||||
}
|
||||
println!("forward-warp segment (600 px brush, 25 px moves) + proxy update: mean {:.2} ms, worst {:.2} ms", total / n as f64, worst);
|
||||
// One 3000 px drag with a 200 px brush (a straight leg, then a diagonal one), as a slow
|
||||
// (every 2 px) and a fast (every 60 px) pointer reports it: the field cost of the whole
|
||||
// stroke, and how far the two results differ.
|
||||
let mut drag = LiquifyStroke::new(LiquifyTool::ForwardWarp, 200.0);
|
||||
let mut fields = Vec::new();
|
||||
let along = |d: f64| if d <= 1500.0 { vec![1500.0 + d, 2000.0] } else { vec![3000.0 + (d - 1500.0) * 0.6, 2000.0 + (d - 1500.0) * 0.8] };
|
||||
for step in [2.0, 60.0] {
|
||||
drag.points = (0..=(3000.0 / step) as usize).map(|k| along(k as f64 * step)).collect();
|
||||
let t = Instant::now();
|
||||
let mut f = LiquifyField::new(b, cell);
|
||||
f.apply_stroke(&drag);
|
||||
println!("forward-warp 3000 px drag (200 px brush), an event every {step} px: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
fields.push(f);
|
||||
}
|
||||
let diff = fields[0].d.iter().zip(&fields[1].d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
|
||||
println!(" slow vs fast pointer: fields differ by at most {diff:.3} px (max displacement {:.1} px)", fields[0].max_displacement());
|
||||
st.tool = LiquifyTool::Bloat;
|
||||
let t = Instant::now();
|
||||
let mut d = Rect::EMPTY;
|
||||
|
|
|
|||
|
|
@ -992,8 +992,9 @@ mod tests {
|
|||
let mixed = crate::apply_in(&s, &p, canvas, canvas, Some(&half), canvas);
|
||||
for (x, y) in [(0, 0), (18, 11), (36, 22)] {
|
||||
let (o, f, m) = (s.pixel(x, y), full.pixel(x, y), mixed.pixel(x, y));
|
||||
for c in 0..4 {
|
||||
assert!((m[c] - (o[c] + f[c]) / 2.0).abs() < 1e-5, "({x},{y}) channel {c}");
|
||||
assert!((m[3] - (o[3] + f[3]) / 2.0).abs() < 1e-5, "({x},{y}) alpha");
|
||||
for c in 0..3 {
|
||||
assert!((m[c] * m[3] - (o[c] * o[3] + f[c] * f[3]) / 2.0).abs() < 1e-5, "({x},{y}) premultiplied channel {c}");
|
||||
}
|
||||
}
|
||||
let cancel = || true;
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ fn apply_inner(
|
|||
return None;
|
||||
}
|
||||
if let Some(selection) = selection {
|
||||
crate::mix_selection(&mut data, output, selection, &src);
|
||||
crate::mix_selection(&mut data, output, selection, &src, fmt.alpha);
|
||||
}
|
||||
if ctl.cancelled() {
|
||||
return None;
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ fn motion_fft_pipeline_supports_depths_models_edges_selections_and_tile_seams()
|
|||
for mask in [None, Some(&selection)] {
|
||||
let mut expected_data = filtered.clone();
|
||||
if let Some(mask) = mask {
|
||||
crate::mix_selection(&mut expected_data, rect, mask, &src);
|
||||
crate::mix_selection(&mut expected_data, rect, mask, &src, alpha);
|
||||
}
|
||||
let mut expected = source.clone();
|
||||
expected.write_region(rect, &expected_data);
|
||||
|
|
@ -362,7 +362,7 @@ fn motion_fft_production_crosses_real_tile_seams_and_leaves_other_pixels_untouch
|
|||
let mut expected = source.clone();
|
||||
let mut data = filtered.clone();
|
||||
if let Some(mask) = mask {
|
||||
crate::mix_selection(&mut data, area, mask, &src);
|
||||
crate::mix_selection(&mut data, area, mask, &src, true);
|
||||
}
|
||||
expected.write_region(area, &data);
|
||||
let expected = Image::read(&expected, check);
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ fn row_surface(surface: &Surface, params: &FilterParams, area: Rect, bounds: Rec
|
|||
let ctx = Ctx { bounds, mode: format.mode, alpha: format.alpha };
|
||||
let mut data = motion_rows(&src, area, &ctx, *angle, *distance);
|
||||
if let Some(selection) = selection {
|
||||
crate::mix_selection(&mut data, area, selection, &src);
|
||||
crate::mix_selection(&mut data, area, selection, &src, format.alpha);
|
||||
}
|
||||
let mut result = surface.clone();
|
||||
result.write_region(area, &data);
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@
|
|||
//! with the guided filter); negative amounts add haze toward the atmospheric light.
|
||||
//! 3. **Tone** on luminance with colour ratios kept: Highlights / Shadows as exposure changes
|
||||
//! weighted by an edge-preserving base layer (guided filter: K. He, J. Sun, X. Tang, *Guided
|
||||
//! Image Filtering*, ECCV 2010), so local contrast survives; Whites / Blacks move the ends of
|
||||
//! the curve; Contrast is an S-curve about middle grey; Clarity (large radius, midtone
|
||||
//! weighted) and Texture (small radius) add band-pass local contrast.
|
||||
//! Image Filtering*, ECCV 2010), so local contrast survives; Contrast is an S-curve about
|
||||
//! middle grey; Clarity (large radius, midtone weighted) and Texture (small radius) add
|
||||
//! band-pass local contrast. Whites / Blacks follow as Photoshop's Camera Raw applies them:
|
||||
//! image-adaptive global curves measured on Photoshop 25.4 (see `whites_blacks`).
|
||||
//! 4. **Presence**: Vibrance (weighted toward less saturated colours) and Saturation.
|
||||
//! 5. **Tone Curve**: parametric regions (Highlights, Lights, Darks, Shadows with movable splits)
|
||||
//! then point curves (master and per channel), monotone cubic interpolation (F. Fritsch,
|
||||
|
|
@ -54,6 +55,38 @@ 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 whites_blacks;
|
||||
|
||||
/// 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)]
|
||||
pub(crate) struct Encoding(f32);
|
||||
|
||||
impl Encoding {
|
||||
pub(crate) fn of(gamma: f32) -> Self {
|
||||
Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 })
|
||||
}
|
||||
pub(crate) fn decode(self, v: f32) -> f32 {
|
||||
if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) }
|
||||
}
|
||||
pub(crate) fn encode(self, v: f32) -> f32 {
|
||||
if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) }
|
||||
}
|
||||
}
|
||||
|
||||
fn srgb_encode(v: f32) -> f32 {
|
||||
linear_to_srgb(v)
|
||||
}
|
||||
|
||||
fn srgb_decode(v: f32) -> f32 {
|
||||
srgb_to_linear(v)
|
||||
}
|
||||
|
||||
fn luma_linear(c: [f32; 3]) -> f32 {
|
||||
luma(c)
|
||||
}
|
||||
|
||||
/// One colour grading wheel.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
|
|
@ -136,6 +169,9 @@ pub struct CameraRaw {
|
|||
pub seed: u32,
|
||||
/// Pixel-size multiplier for previews on downsampled proxies (radii in px scale by it).
|
||||
pub pixel_scale: f32,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl Default for CameraRaw {
|
||||
|
|
@ -191,6 +227,7 @@ impl Default for CameraRaw {
|
|||
vignette_style: "highlightPriority".into(),
|
||||
seed: 0,
|
||||
pixel_scale: 1.0,
|
||||
encoding_gamma: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -491,7 +528,11 @@ impl CameraRaw {
|
|||
self.temperature == 0.0 && self.tint == 0.0 && self.exposure == 0.0
|
||||
}
|
||||
fn tone_neutral(&self) -> bool {
|
||||
[self.contrast, self.highlights, self.shadows, self.whites, self.blacks, self.texture, self.clarity].iter().all(|v| *v == 0.0)
|
||||
self.local_tone_neutral() && self.whites == 0.0 && self.blacks == 0.0
|
||||
}
|
||||
/// Contrast, Highlights, Shadows, Texture and Clarity (the luminance stage).
|
||||
fn local_tone_neutral(&self) -> bool {
|
||||
[self.contrast, self.highlights, self.shadows, self.texture, self.clarity].iter().all(|v| *v == 0.0)
|
||||
}
|
||||
fn hsl_neutral(&self) -> bool {
|
||||
self.hsl_hue.iter().chain(&self.hsl_sat).chain(&self.hsl_lum).all(|v| *v == 0.0)
|
||||
|
|
@ -561,16 +602,13 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
|||
if p.dehaze != 0.0 {
|
||||
dehaze(px, w, h, p.dehaze / 100.0);
|
||||
}
|
||||
// 3. Tone on luminance.
|
||||
if !p.tone_neutral() {
|
||||
// 3. Tone. Highlights and Shadows: a local Laplacian filter (see `local_tone`).
|
||||
if p.highlights != 0.0 || p.shadows != 0.0 {
|
||||
local_tone::apply(px, w, h, p.shadows, p.highlights, Encoding::of(p.encoding_gamma));
|
||||
}
|
||||
if !p.local_tone_neutral() && [p.contrast, p.texture, p.clarity].iter().any(|v| *v != 0.0) {
|
||||
let l: Vec<f32> = px.iter().map(|q| luma([q[0], q[1], q[2]]).max(0.0)).collect();
|
||||
let lc: Vec<f32> = l.iter().map(|v| v.min(1.0)).collect();
|
||||
let base = if p.highlights != 0.0 || p.shadows != 0.0 {
|
||||
let r = ((long * 0.015) as usize).max(2);
|
||||
Some(guided_fast(&lc, &lc, w, h, r, 0.02))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
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;
|
||||
|
|
@ -580,12 +618,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
|||
let i = y * w + x;
|
||||
let l0 = l[i];
|
||||
let mut lv = lc[i];
|
||||
if let Some(b) = &base {
|
||||
let bv = b[i].clamp(0.0, 1.0);
|
||||
let hm = smoothstep(0.45, 0.95, bv);
|
||||
let sm = 1.0 - smoothstep(0.05, 0.5, bv);
|
||||
lv *= 2f32.powf(p.highlights / 100.0 * 1.2 * hm + p.shadows / 100.0 * 1.4 * sm);
|
||||
}
|
||||
if let Some(c) = &clar {
|
||||
let mw = 1.0 - (2.0 * lv.clamp(0.0, 1.0) - 1.0).powi(2);
|
||||
lv += p.clarity / 100.0 * 0.8 * (lc[i] - c[i]) * mw;
|
||||
|
|
@ -593,12 +625,6 @@ 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 p.whites != 0.0 {
|
||||
lv += p.whites / 100.0 * 0.25 * smoothstep(0.4, 1.0, lv);
|
||||
}
|
||||
if p.blacks != 0.0 {
|
||||
lv += p.blacks / 100.0 * 0.15 * (1.0 - smoothstep(0.0, 0.45, lv));
|
||||
}
|
||||
if k_con != 0.0 {
|
||||
let xx = lv.clamp(0.0, 1.0);
|
||||
lv += -k_con * (tau * xx).sin() / tau;
|
||||
|
|
@ -611,6 +637,13 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
|||
}
|
||||
});
|
||||
}
|
||||
// 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 (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);
|
||||
}
|
||||
// 4–7: per-pixel colour stages.
|
||||
let need_color = p.vibrance != 0.0 || p.saturation != 0.0 || !p.curves_neutral() || !p.hsl_neutral() || !p.grade_neutral();
|
||||
if need_color {
|
||||
|
|
@ -732,6 +765,36 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
|||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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));
|
||||
}
|
||||
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();
|
||||
if cells.is_empty() {
|
||||
return (0.0, 1.0);
|
||||
}
|
||||
let pct = |mut v: Vec<f32>, q: f32| {
|
||||
v.sort_by(f32::total_cmp);
|
||||
v[((v.len() - 1) as f32 * q).round() as usize]
|
||||
};
|
||||
let bp = pct(cells.iter().map(|c| luma(*c)).collect(), 0.03);
|
||||
let wp = pct(cells.iter().map(|c| c[0].max(c[1]).max(c[2])).collect(), 0.995);
|
||||
(linear_to_srgb(bp), linear_to_srgb(wp))
|
||||
}
|
||||
|
||||
fn value_noise(x: f32, y: f32, seed: u64) -> f32 {
|
||||
let (xi, yi) = (x.floor(), y.floor());
|
||||
let (tx, ty) = (x - xi, y - yi);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,428 @@
|
|||
//! Shadows and Highlights as a local Laplacian filter.
|
||||
//!
|
||||
//! Adobe has said Camera Raw's process version 2012 rests on local Laplacian filters (S. Paris,
|
||||
//! S. W. Hasinoff, J. Kautz, *Local Laplacian Filters: Edge-aware Image Processing with a
|
||||
//! Laplacian Pyramid*, SIGGRAPH 2011). This follows that paper, in its fast form (M. Aubry et
|
||||
//! al., *Fast Local Laplacian Filters*, ACM TOG 2014: the remapping sampled at a few reference
|
||||
//! values, each pyramid coefficient interpolated between them), on the log2 luminance:
|
||||
//!
|
||||
//! - **Shadows** raises pixels darker than their surroundings, by an amount set by their own and
|
||||
//! their surroundings' place in the image's range (1st to 99th percentile); **Highlights**
|
||||
//! pulls pixels brighter than their surroundings toward them. Differences under a threshold
|
||||
//! are left alone, so fine detail stays.
|
||||
//! - Shadows then keeps the image's 99th percentile, Highlights its 1st (Photoshop keeps the
|
||||
//! brightest content under Shadows and the darkest under Highlights).
|
||||
//! - A uniform image is unchanged, edges keep no halo, and the result does not depend on the
|
||||
//! image size.
|
||||
//!
|
||||
//! The strengths were fitted to Photoshop 25.4's Camera Raw Filter on ten public-domain photos
|
||||
//! (black-box measurements, no Adobe code or data). It is an approximation: on those photos the
|
||||
//! mean error against Photoshop is 2.8–9.4 levels (of 255) where the previous heuristic's was
|
||||
//! 3.7–14.3.
|
||||
|
||||
use super::{Encoding, luma};
|
||||
use crate::photo_util::par_rows;
|
||||
|
||||
/// Spacing of the fast filter's reference values in stops. Its remapping changes within about
|
||||
/// half a stop, and coefficients interpolated across wider gaps show as contour lines on smooth
|
||||
/// gradients (12 references over a whole raw's range left visible steps; at an eighth of a stop a
|
||||
/// ramp's slope varies by under a third, as the remapping itself makes it).
|
||||
const REF_STEP: f32 = 0.125;
|
||||
/// The filter runs on a copy at most this many pixels across; the change it makes is brought back
|
||||
/// to full size by a guided upsampling, so a preview and the full image agree.
|
||||
const WORK_SIDE: usize = 1600;
|
||||
/// Knots of the strength curves over the surroundings' place in the image range (0 … 1).
|
||||
const KNOTS: usize = 5;
|
||||
|
||||
/// Shadows: how far a pixel darker than its surroundings rises, in stops per unit slider, as
|
||||
/// a function of its own place in the image range (`own`) times a factor of its surroundings'
|
||||
/// place (`around`); it rises fully once it is `sigma` stops below them. The lift depends on
|
||||
/// the pixel's tone, not on the size of the difference, so it is the same at every pyramid
|
||||
/// level and a big edge keeps no halo. Lowering (negative values) is `down` times as strong.
|
||||
const SHADOWS: Lift = Lift { own: [-0.017, 0.466, 0.886, 1.221, -0.081], around: [4.566, 4.067, 1.088, 0.149, 0.878], sigma: 0.571, down: 0.808 };
|
||||
const HIGHLIGHTS: Side = Side { pull: [0.179, 0.141, 0.196, 0.629, 2.07], push: [0.156, 0.08, 0.175, 0.049, 1.261], sigma: 0.05 };
|
||||
|
||||
/// See [`SHADOWS`].
|
||||
struct Lift {
|
||||
own: [f32; KNOTS],
|
||||
around: [f32; KNOTS],
|
||||
sigma: f32,
|
||||
down: f32,
|
||||
}
|
||||
|
||||
/// Highlights: strengths per unit slider over the surroundings' place in the image range, for
|
||||
/// lowering (`pull`, toward the surroundings) and raising (`push`), and the detail threshold in
|
||||
/// stops.
|
||||
struct Side {
|
||||
pull: [f32; KNOTS],
|
||||
push: [f32; KNOTS],
|
||||
sigma: f32,
|
||||
}
|
||||
|
||||
/// One channel, `w × h`.
|
||||
struct Plane {
|
||||
w: usize,
|
||||
h: usize,
|
||||
v: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Mirrors `i` into `0..n` (the edge sample is not repeated).
|
||||
fn mirror(i: isize, n: usize) -> usize {
|
||||
let n = n as isize;
|
||||
if n == 1 {
|
||||
return 0;
|
||||
}
|
||||
let p = 2 * (n - 1);
|
||||
let m = i.rem_euclid(p);
|
||||
(if m >= n { p - m } else { m }) as usize
|
||||
}
|
||||
|
||||
const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
|
||||
|
||||
/// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution).
|
||||
fn blur_step(p: &Plane, step: usize) -> Plane {
|
||||
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
|
||||
// Columns first at the kept rows, then rows at the kept columns.
|
||||
let mut tmp = vec![0.0f32; p.w * h2];
|
||||
par_rows(&mut tmp, p.w, 1, |y2, row| {
|
||||
let y = (y2 * step) as isize;
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum();
|
||||
}
|
||||
});
|
||||
let mut out = vec![0.0f32; w2 * h2];
|
||||
par_rows(&mut out, w2, 1, |y2, row| {
|
||||
let src = &tmp[y2 * p.w..(y2 + 1) * p.w];
|
||||
for (x2, o) in row.iter_mut().enumerate() {
|
||||
let x = (x2 * step) as isize;
|
||||
*o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum();
|
||||
}
|
||||
});
|
||||
Plane { w: w2, h: h2, v: out }
|
||||
}
|
||||
|
||||
/// The next coarser level.
|
||||
fn down(p: &Plane) -> Plane {
|
||||
blur_step(p, 2)
|
||||
}
|
||||
|
||||
/// `p` brought up to `w × h`: zeros between samples, then the blur times four.
|
||||
fn up(p: &Plane, w: usize, h: usize) -> Plane {
|
||||
let mut z = Plane { w, h, v: vec![0.0; w * h] };
|
||||
for y in 0..p.h.min(h.div_ceil(2)) {
|
||||
for x in 0..p.w.min(w.div_ceil(2)) {
|
||||
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
|
||||
}
|
||||
}
|
||||
blur_step(&z, 1)
|
||||
}
|
||||
|
||||
/// Levels down to about 2 pixels on the short side.
|
||||
fn depth(w: usize, h: usize) -> usize {
|
||||
let mut s = w.min(h);
|
||||
let mut n = 1;
|
||||
while s > 2 {
|
||||
s = s.div_ceil(2);
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn gaussian(p: Plane, n: usize) -> Vec<Plane> {
|
||||
let mut g = vec![p];
|
||||
for _ in 1..n {
|
||||
let next = down(&g[g.len() - 1]);
|
||||
g.push(next);
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// The local Laplacian filter of `u` with `remap(value, reference)`; the coarsest level is kept.
|
||||
fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
|
||||
let n = depth(u.w, u.h);
|
||||
let g = gaussian(Plane { w: u.w, h: u.h, v: u.v.clone() }, n);
|
||||
// References span the image's tones; the deepest 0.1 % (noise down to the floor) share the
|
||||
// lowest reference instead of stretching the spacing.
|
||||
let hi = u.v.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v));
|
||||
let lo = quantile(&u.v, 0.001).min(hi);
|
||||
if hi - lo <= 1e-6 || (hi - lo).is_nan() {
|
||||
return Plane { w: u.w, h: u.h, v: u.v.clone() };
|
||||
}
|
||||
let refs = (((hi - lo) / REF_STEP).ceil() as usize + 1).clamp(2, 96);
|
||||
let step = (hi - lo) / (refs - 1) as f32;
|
||||
let mut lap: Vec<Vec<f32>> = g[..n - 1].iter().map(|p| vec![0.0; p.v.len()]).collect();
|
||||
for k in 0..refs {
|
||||
let r = lo + step * k as f32;
|
||||
let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() };
|
||||
par_rows(&mut m.v, u.w, 1, |_, row| {
|
||||
for v in row.iter_mut() {
|
||||
*v = remap(*v, r);
|
||||
}
|
||||
});
|
||||
let gr = gaussian(m, n);
|
||||
for l in 0..n - 1 {
|
||||
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
|
||||
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
|
||||
par_rows(&mut lap[l], lw, 1, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let i = y * lw + x;
|
||||
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
|
||||
if wgt > 0.0 {
|
||||
*o += wgt * (fine[i] - coarse.v[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut o = Plane { w: g[n - 1].w, h: g[n - 1].h, v: g[n - 1].v.clone() };
|
||||
for l in (0..n - 1).rev() {
|
||||
let mut next = up(&o, g[l].w, g[l].h);
|
||||
for (a, b) in next.v.iter_mut().zip(&lap[l]) {
|
||||
*a += b;
|
||||
}
|
||||
o = next;
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The `q` quantile (0 … 1) of `v`.
|
||||
fn quantile(v: &[f32], q: f32) -> f32 {
|
||||
let mut s = v.to_vec();
|
||||
s.sort_by(f32::total_cmp);
|
||||
s[((s.len() - 1) as f32 * q).round() as usize]
|
||||
}
|
||||
|
||||
/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across.
|
||||
fn range(u: &Plane) -> (f32, f32) {
|
||||
let f = u.w.max(u.h).div_ceil(256).max(1);
|
||||
let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f));
|
||||
let mut sum = vec![(0.0f64, 0u32); sw * sh];
|
||||
for y in 0..u.h {
|
||||
for x in 0..u.w {
|
||||
let c = &mut sum[(y / f) * sw + x / f];
|
||||
c.0 += f64::from(u.v[y * u.w + x]);
|
||||
c.1 += 1;
|
||||
}
|
||||
}
|
||||
let cells: Vec<f32> = sum.iter().filter(|c| c.1 > 0).map(|c| (c.0 / f64::from(c.1)) as f32).collect();
|
||||
(quantile(&cells, 0.01), quantile(&cells, 0.99))
|
||||
}
|
||||
|
||||
/// `d` past the detail threshold: about 0 below `sigma`, `d − sigma` well above it.
|
||||
fn excess(d: f32, sigma: f32) -> f32 {
|
||||
d - sigma * (d / sigma).tanh()
|
||||
}
|
||||
|
||||
fn knot(t: f32, k: &[f32; KNOTS]) -> f32 {
|
||||
let x = t.clamp(0.0, 1.0) * (KNOTS - 1) as f32;
|
||||
let i = (x as usize).min(KNOTS - 2);
|
||||
k[i] + (k[i + 1] - k[i]) * (x - i as f32)
|
||||
}
|
||||
|
||||
/// The Highlights pass on the log luminance `u` (`amount` −1 … 1): pixels brighter than their
|
||||
/// surroundings move toward them (or away, for positive values) by the part of the difference
|
||||
/// past `sigma`, then the darkest content is put back where it was.
|
||||
fn highlights_pass(u: &Plane, amount: f32, side: &Side) -> Plane {
|
||||
let (lo, hi) = range(u);
|
||||
let span = (hi - lo).max(1.0);
|
||||
let strengths = if amount < 0.0 { &side.pull } else { &side.push };
|
||||
let o = llf(u, |x, g| x + knot((g - lo) / span, strengths) * amount * excess((x - g).max(0.0), side.sigma));
|
||||
let shift = quantile(&u.v, 0.01) - quantile(&o.v, 0.01);
|
||||
Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() }
|
||||
}
|
||||
|
||||
/// The Shadows pass on the log luminance `u` (`amount` −1 … 1).
|
||||
fn shadows_pass(u: &Plane, amount: f32) -> Plane {
|
||||
let (lo, hi) = range(u);
|
||||
let span = (hi - lo).max(1.0);
|
||||
let k = if amount > 0.0 { amount } else { amount * SHADOWS.down };
|
||||
let o = llf(u, |x, g| {
|
||||
let below = ((g - x) / SHADOWS.sigma).clamp(0.0, 1.0);
|
||||
if below == 0.0 {
|
||||
return x;
|
||||
}
|
||||
let w = below * below * (3.0 - 2.0 * below);
|
||||
x + k * knot((x - lo) / span, &SHADOWS.own) * knot((g - lo) / span, &SHADOWS.around) * w
|
||||
});
|
||||
// Keep the brightest content.
|
||||
let shift = quantile(&u.v, 0.99) - quantile(&o.v, 0.99);
|
||||
Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() }
|
||||
}
|
||||
|
||||
/// Applies Shadows and Highlights (−100 … 100) to straight RGBA pixels encoded with `enc`.
|
||||
pub(crate) fn apply(px: &mut [[f32; 4]], w: usize, h: usize, shadows: f32, highlights: f32, enc: Encoding) {
|
||||
if (shadows == 0.0 && highlights == 0.0) || w < 2 || h < 2 {
|
||||
return;
|
||||
}
|
||||
const FLOOR: f32 = 1.0 / 65536.0;
|
||||
let u = Plane { w, h, v: px.iter().map(|q| (luma([0, 1, 2].map(|c| enc.decode(q[c]))).max(0.0) + FLOOR).log2()).collect() };
|
||||
// The filter on a copy at most WORK_SIDE across (box-averaged in log).
|
||||
let f = w.max(h).div_ceil(WORK_SIDE).max(1);
|
||||
let small = if f == 1 { Plane { w, h, v: u.v.clone() } } else { shrink(&u, f) };
|
||||
let mut o = Plane { w: small.w, h: small.h, v: small.v.clone() };
|
||||
if highlights != 0.0 {
|
||||
o = highlights_pass(&o, highlights.clamp(-100.0, 100.0) / 100.0, &HIGHLIGHTS);
|
||||
}
|
||||
if shadows != 0.0 {
|
||||
o = shadows_pass(&o, shadows.clamp(-100.0, 100.0) / 100.0);
|
||||
}
|
||||
// The change in log luminance, at full size.
|
||||
let change: Vec<f32> = if f == 1 {
|
||||
o.v.iter().zip(&u.v).map(|(a, b)| a - b).collect()
|
||||
} else {
|
||||
let d: Vec<f32> = o.v.iter().zip(&small.v).map(|(a, b)| a - b).collect();
|
||||
guided_upsample(&small, &d, &u)
|
||||
};
|
||||
par_rows(px, w, 1, |y, row| {
|
||||
for (x, q) in row.iter_mut().enumerate() {
|
||||
let gain = change[y * w + x].exp2();
|
||||
for c in 0..3 {
|
||||
q[c] = enc.encode(enc.decode(q[c]) * gain);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `u` averaged over `f × f` blocks.
|
||||
fn shrink(u: &Plane, f: usize) -> Plane {
|
||||
let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f));
|
||||
let mut v = vec![0.0f32; sw * sh];
|
||||
par_rows(&mut v, sw, 1, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let (x0, y0) = (x * f, y * f);
|
||||
let (x1, y1) = ((x0 + f).min(u.w), (y0 + f).min(u.h));
|
||||
let mut s = 0.0f64;
|
||||
for yy in y0..y1 {
|
||||
for xx in x0..x1 {
|
||||
s += f64::from(u.v[yy * u.w + xx]);
|
||||
}
|
||||
}
|
||||
*o = (s / ((x1 - x0) * (y1 - y0)) as f64) as f32;
|
||||
}
|
||||
});
|
||||
Plane { w: sw, h: sh, v }
|
||||
}
|
||||
|
||||
/// The change `d` computed on `small` brought to the size of `full`, following its edges: a local
|
||||
/// linear model of the change against the log luminance (the guided filter of K. He, J. Sun,
|
||||
/// *Fast Guided Filter*, 2015), fitted on the small copy and evaluated on the full one.
|
||||
fn guided_upsample(small: &Plane, d: &[f32], full: &Plane) -> Vec<f32> {
|
||||
let (sw, sh) = (small.w, small.h);
|
||||
let r = 2;
|
||||
let eps = 0.01;
|
||||
let mean = |v: &[f32]| super::box_mean(v, sw, sh, r);
|
||||
let ud: Vec<f32> = small.v.iter().zip(d).map(|(a, b)| a * b).collect();
|
||||
let uu: Vec<f32> = small.v.iter().map(|a| a * a).collect();
|
||||
let (mu, md, mud, muu) = (mean(&small.v), mean(d), mean(&ud), mean(&uu));
|
||||
let a: Vec<f32> = (0..sw * sh).map(|i| (mud[i] - mu[i] * md[i]) / (muu[i] - mu[i] * mu[i] + eps)).collect();
|
||||
let b: Vec<f32> = (0..sw * sh).map(|i| md[i] - a[i] * mu[i]).collect();
|
||||
let (ma, mb) = (mean(&a), mean(&b));
|
||||
let (fx, fy) = (sw as f32 / full.w as f32, sh as f32 / full.h as f32);
|
||||
let mut out = vec![0.0f32; full.w * full.h];
|
||||
par_rows(&mut out, full.w, 1, |y, row| {
|
||||
let sy = (y as f32 + 0.5) * fy - 0.5;
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let sx = (x as f32 + 0.5) * fx - 0.5;
|
||||
let av = crate::photo_util::bilinear(&ma, sw, sh, 1, 0, sx, sy);
|
||||
let bv = crate::photo_util::bilinear(&mb, sw, sh, 1, 0, sx, sy);
|
||||
*o = av * full.v[y * full.w + x] + bv;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SRGB: Encoding = Encoding(0.0);
|
||||
|
||||
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
|
||||
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_image_is_unchanged() {
|
||||
let mut px = grey(64, 48, |_, _| 0.4);
|
||||
apply(&mut px, 64, 48, 100.0, -100.0, SRGB);
|
||||
assert!(px.iter().all(|q| (q[0] - 0.4).abs() < 1e-5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadows_raise_a_dark_area_without_a_halo() {
|
||||
// A 0.1 | 0.6 step (the dark half is the image's black point, which Photoshop barely
|
||||
// moves either): the bright half stays, and no halo forms on either side of the edge.
|
||||
let (w, h) = (256, 128);
|
||||
let mut px = grey(w, h, |x, _| if x < w / 2 { 0.1 } else { 0.6 });
|
||||
apply(&mut px, w, h, 100.0, 0.0, SRGB);
|
||||
let row = &px[64 * w..65 * w];
|
||||
assert!((row[w / 2 + 1][0] - 0.6).abs() < 0.002 && (row[w - 5][0] - 0.6).abs() < 0.002);
|
||||
let (far, near) = (row[10][0], row[w / 2 - 1][0]);
|
||||
assert!((far - 0.1).abs() < 0.004 && (far - near).abs() < 0.004, "{far} {near}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grey_moves_as_in_photoshop() {
|
||||
// Mid-grey with 1 % black (0.015) and white (0.97) corners, Photoshop 25.4: Shadows +100
|
||||
// takes the grey from 127.5 to 165.3, Highlights −100 to 89.6 (of 255).
|
||||
let n = 256;
|
||||
let image = |c: f32| {
|
||||
grey(n, n, |x, y| {
|
||||
if x >= n - 26 && y >= n - 26 {
|
||||
0.97
|
||||
} else if x >= n - 26 && y < 26 {
|
||||
0.015
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
};
|
||||
for (s, hl, want) in [(100.0, 0.0, 165.3), (0.0, -100.0, 89.6)] {
|
||||
let mut px = image(0.5);
|
||||
apply(&mut px, n, n, s, hl, SRGB);
|
||||
let got = px[175 * n + 100][0] * 255.0;
|
||||
assert!((got - want).abs() < 5.0, "{s} {hl}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlights_lower_a_bright_area_and_keep_the_darkest() {
|
||||
let (w, h) = (192, 192);
|
||||
let mut px = grey(w, h, |x, y| if (64..128).contains(&x) && (64..128).contains(&y) { 0.8 } else { 0.2 });
|
||||
apply(&mut px, w, h, 0.0, -100.0, SRGB);
|
||||
assert!(px[96 * w + 96][0] < 0.75, "{}", px[96 * w + 96][0]);
|
||||
assert!((px[5 * w + 5][0] - 0.2).abs() < 0.01, "{}", px[5 * w + 5][0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_gradients_stay_smooth() {
|
||||
// A soft ramp over eight stops, as in an out-of-focus background. Coefficients interpolated
|
||||
// between references too far apart drew contour lines across such ramps (Shadows +100 on
|
||||
// a D4 raw): the output must rise steadily, without ripples.
|
||||
let (w, h) = (640, 48);
|
||||
let mut px = grey(w, h, |x, _| crate::photo_util::linear_to_srgb((x as f32 / (w - 1) as f32 * 8.0 - 9.0).exp2()));
|
||||
for (s, hl) in [(100.0, 0.0), (0.0, -100.0), (-100.0, 100.0)] {
|
||||
let mut p = px.clone();
|
||||
apply(&mut p, w, h, s, hl, SRGB);
|
||||
let row: Vec<f32> = (0..w).map(|x| crate::photo_util::srgb_to_linear(p[24 * w + x][0]).max(1e-6).log2()).collect();
|
||||
let steps: Vec<f32> = row.windows(2).map(|d| d[1] - d[0]).collect();
|
||||
assert!(steps.iter().all(|d| *d > 0.0), "{s} {hl}: the ramp reverses");
|
||||
let jumps: Vec<f32> = steps.windows(2).map(|d| (d[1] - d[0]).abs()).collect();
|
||||
let ripple = jumps[16..jumps.len() - 16].iter().fold(0.0f32, |a, b| a.max(*b));
|
||||
// The ramp rises 0.0125 stops per pixel; its slope may change by a third at most.
|
||||
assert!(ripple < 0.004, "{s} {hl}: slope jumps by {ripple} stops per pixel");
|
||||
}
|
||||
px.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_result_does_not_depend_on_the_image_size() {
|
||||
let at = |n: usize| {
|
||||
let mut px = grey(n, n, |x, y| if x < n / 3 || y < n / 4 { 0.08 } else { 0.7 });
|
||||
apply(&mut px, n, n, 70.0, -60.0, SRGB);
|
||||
[px[(n / 8) * n + n / 8][0], px[(3 * n / 4) * n + 3 * n / 4][0]]
|
||||
};
|
||||
let (a, b) = (at(96), at(384));
|
||||
assert!((a[0] - b[0]).abs() < 0.02 && (a[1] - b[1]).abs() < 0.02, "{a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,418 @@
|
|||
//! Whites and Blacks as Adobe Camera Raw (process version 2012) applies them.
|
||||
//!
|
||||
//! Measured as a black box on the Camera Raw Filter of Photoshop 25.4: grey blocks with chosen
|
||||
//! histograms went through the filter by script and came back exactly; no Adobe code or data
|
||||
//! was used.
|
||||
//!
|
||||
//! - Both are global curves of the grey level, applied after Shadows and Highlights, Whites
|
||||
//! first. On an image whose darkest and lightest percent reach black and white the curve of a
|
||||
//! slider value `x` is the measured row at `x` of [`WHITES`] or [`BLACKS`].
|
||||
//! - The direction that only compresses (Whites down, Blacks up) is that fixed curve on every
|
||||
//! image. The direction that clips (Whites up, Blacks down) adapts: the row used is
|
||||
//! `x · A(BP, WP)`. On the block images BP and WP are their darkest and lightest levels; on
|
||||
//! photographs Photoshop's amounts follow best with BP the 3rd percentile of the luminance and
|
||||
//! WP the 99.5th of the brightest channel, and Blacks at least 1.2 times. A low
|
||||
//! white point makes Whites +100 stronger (up to 2.7 times), a raised black point Blacks
|
||||
//! (up to 1.45 times at −40). Whites rows past +100 come from images where `A > 1`.
|
||||
//! - Not reproduced: past −40 Photoshop's Blacks grows faster on images with a raised black
|
||||
//! point (on one spanning 0.6–1 its −100 blacks out everything below 0.74, here below about
|
||||
//! 0.15); its curves there are not rows of the full-range family.
|
||||
//! - Colour: the clipping directions act on each channel, the compressing ones scale the pixel
|
||||
//! by the curve's change of its luminance (closest to Photoshop on saturated colours).
|
||||
|
||||
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;
|
||||
/// The first row's internal amount; rows are 10 apart.
|
||||
const WHITES_FROM: f32 = -100.0;
|
||||
const BLACKS_FROM: f32 = -100.0;
|
||||
// Measured levels; some happen to lie near π/8 and π/4.
|
||||
/// Whites curves at internal amounts -100, -90, …, 270 (rows) over grey levels 0, 1/32, …, 1.
|
||||
#[allow(clippy::approx_constant)]
|
||||
static WHITES: [[f32; 33]; 38] = [
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2391, 0.2671, 0.2946, 0.3215, 0.3480, 0.3738, 0.3995, 0.4256, 0.4517, 0.4774, 0.5028,
|
||||
0.5278, 0.5524, 0.5768, 0.6008, 0.6245, 0.6482, 0.6717, 0.6957, 0.7207, 0.7483, 0.7814, 0.8263, 0.8949, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2392, 0.2672, 0.2947, 0.3217, 0.3482, 0.3742, 0.4000, 0.4263, 0.4526, 0.4785, 0.5042,
|
||||
0.5296, 0.5548, 0.5797, 0.6044, 0.6291, 0.6539, 0.6789, 0.7046, 0.7317, 0.7617, 0.7969, 0.8425, 0.9074, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2108, 0.2393, 0.2673, 0.2950, 0.3221, 0.3487, 0.3748, 0.4008, 0.4274, 0.4540, 0.4803, 0.5064,
|
||||
0.5323, 0.5580, 0.5837, 0.6093, 0.6350, 0.6610, 0.6875, 0.7149, 0.7438, 0.7756, 0.8122, 0.8576, 0.9184, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2109, 0.2395, 0.2677, 0.2954, 0.3227, 0.3495, 0.3759, 0.4022, 0.4291, 0.4561, 0.4828, 0.5094,
|
||||
0.5360, 0.5624, 0.5888, 0.6154, 0.6423, 0.6695, 0.6973, 0.7261, 0.7566, 0.7897, 0.8271, 0.8716, 0.9280, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1525, 0.1820, 0.2111, 0.2398, 0.2682, 0.2961, 0.3237, 0.3508, 0.3775, 0.4042, 0.4317, 0.4591, 0.4864, 0.5136,
|
||||
0.5408, 0.5680, 0.5953, 0.6229, 0.6508, 0.6791, 0.7082, 0.7382, 0.7698, 0.8037, 0.8412, 0.8843, 0.9365, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0927, 0.1227, 0.1527, 0.1822, 0.2116, 0.2404, 0.2690, 0.2972, 0.3251, 0.3527, 0.3799, 0.4071, 0.4351, 0.4630, 0.4910, 0.5189,
|
||||
0.5469, 0.5749, 0.6031, 0.6316, 0.6605, 0.6899, 0.7199, 0.7509, 0.7832, 0.8174, 0.8544, 0.8958, 0.9439, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0927, 0.1229, 0.1529, 0.1827, 0.2122, 0.2414, 0.2702, 0.2989, 0.3272, 0.3553, 0.3831, 0.4110, 0.4396, 0.4682, 0.4968, 0.5254,
|
||||
0.5542, 0.5830, 0.6121, 0.6414, 0.6712, 0.7014, 0.7322, 0.7637, 0.7964, 0.8305, 0.8668, 0.9062, 0.9503, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0622, 0.0928, 0.1231, 0.1534, 0.1834, 0.2132, 0.2427, 0.2720, 0.3011, 0.3300, 0.3587, 0.3872, 0.4160, 0.4452, 0.4745, 0.5038, 0.5332,
|
||||
0.5627, 0.5923, 0.6221, 0.6522, 0.6826, 0.7134, 0.7447, 0.7765, 0.8092, 0.8429, 0.8781, 0.9155, 0.9559, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0623, 0.0930, 0.1236, 0.1541, 0.1844, 0.2145, 0.2445, 0.2744, 0.3041, 0.3337, 0.3632, 0.3925, 0.4221, 0.4520, 0.4820, 0.5120, 0.5421,
|
||||
0.5723, 0.6026, 0.6330, 0.6636, 0.6946, 0.7257, 0.7572, 0.7890, 0.8214, 0.8545, 0.8885, 0.9237, 0.9608, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0624, 0.0934, 0.1242, 0.1551, 0.1858, 0.2164, 0.2470, 0.2775, 0.3080, 0.3383, 0.3687, 0.3989, 0.4293, 0.4600, 0.4906, 0.5213, 0.5520,
|
||||
0.5827, 0.6136, 0.6445, 0.6755, 0.7067, 0.7380, 0.7694, 0.8011, 0.8330, 0.8652, 0.8978, 0.9310, 0.9651, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625,
|
||||
0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0626, 0.0940, 0.1253, 0.1567, 0.1881, 0.2195, 0.2510, 0.2825, 0.3140, 0.3456, 0.3772, 0.4089, 0.4405, 0.4720, 0.5036, 0.5351, 0.5667,
|
||||
0.5983, 0.6298, 0.6613, 0.6929, 0.7244, 0.7559, 0.7874, 0.8189, 0.8503, 0.8816, 0.9128, 0.9438, 0.9742, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0627, 0.0941, 0.1257, 0.1573, 0.1890, 0.2208, 0.2526, 0.2846, 0.3167, 0.3488, 0.3811, 0.4134, 0.4456, 0.4777, 0.5098, 0.5419, 0.5741,
|
||||
0.6062, 0.6383, 0.6704, 0.7025, 0.7345, 0.7664, 0.7983, 0.8301, 0.8617, 0.8931, 0.9241, 0.9543, 0.9829, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0628, 0.0944, 0.1262, 0.1582, 0.1903, 0.2225, 0.2550, 0.2876, 0.3204, 0.3534, 0.3867, 0.4200, 0.4529, 0.4858, 0.5186, 0.5516, 0.5845,
|
||||
0.6174, 0.6502, 0.6830, 0.7157, 0.7482, 0.7807, 0.8131, 0.8451, 0.8769, 0.9082, 0.9385, 0.9673, 0.9928, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0629, 0.0948, 0.1269, 0.1594, 0.1920, 0.2249, 0.2582, 0.2917, 0.3256, 0.3597, 0.3942, 0.4289, 0.4628, 0.4966, 0.5305, 0.5644, 0.5983,
|
||||
0.6321, 0.6658, 0.6994, 0.7327, 0.7660, 0.7989, 0.8316, 0.8639, 0.8956, 0.9263, 0.9553, 0.9815, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0631, 0.0953, 0.1280, 0.1610, 0.1944, 0.2282, 0.2625, 0.2973, 0.3325, 0.3683, 0.4045, 0.4407, 0.4758, 0.5109, 0.5460, 0.5811, 0.6161,
|
||||
0.6510, 0.6857, 0.7201, 0.7541, 0.7879, 0.8213, 0.8543, 0.8864, 0.9175, 0.9468, 0.9733, 0.9950, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0633, 0.0961, 0.1293, 0.1632, 0.1976, 0.2327, 0.2684, 0.3048, 0.3419, 0.3798, 0.4183, 0.4562, 0.4928, 0.5294, 0.5660, 0.6024, 0.6387,
|
||||
0.6748, 0.7104, 0.7456, 0.7803, 0.8145, 0.8480, 0.8807, 0.9121, 0.9417, 0.9684, 0.9907, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0300, 0.0637, 0.0971, 0.1313, 0.1663, 0.2020, 0.2387, 0.2763, 0.3149, 0.3546, 0.3952, 0.4365, 0.4763, 0.5149, 0.5532, 0.5914, 0.6294, 0.6670,
|
||||
0.7041, 0.7406, 0.7763, 0.8113, 0.8456, 0.8788, 0.9104, 0.9401, 0.9667, 0.9888, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0300, 0.0641, 0.0984, 0.1338, 0.1703, 0.2079, 0.2468, 0.2870, 0.3286, 0.3717, 0.4161, 0.4602, 0.5024, 0.5430, 0.5833, 0.6232, 0.6627, 0.7015,
|
||||
0.7394, 0.7764, 0.8123, 0.8472, 0.8808, 0.9126, 0.9420, 0.9681, 0.9896, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0302, 0.0647, 0.1003, 0.1374, 0.1760, 0.2160, 0.2579, 0.3016, 0.3472, 0.3949, 0.4436, 0.4908, 0.5355, 0.5784, 0.6207, 0.6622, 0.7029, 0.7424,
|
||||
0.7806, 0.8176, 0.8530, 0.8868, 0.9185, 0.9474, 0.9726, 0.9926, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0304, 0.0656, 0.1029, 0.1423, 0.1837, 0.2271, 0.2731, 0.3215, 0.3726, 0.4262, 0.4791, 0.5294, 0.5769, 0.6220, 0.6660, 0.7086, 0.7498, 0.7893,
|
||||
0.8272, 0.8631, 0.8969, 0.9281, 0.9560, 0.9795, 0.9967, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0305, 0.0679, 0.1094, 0.1509, 0.1968, 0.2459, 0.2956, 0.3510, 0.4094, 0.4680, 0.5248, 0.5781, 0.6278, 0.6742, 0.7195, 0.7624, 0.8031, 0.8417,
|
||||
0.8779, 0.9113, 0.9414, 0.9675, 0.9886, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0306, 0.0702, 0.1160, 0.1623, 0.2144, 0.2709, 0.3243, 0.3885, 0.4556, 0.5199, 0.5807, 0.6369, 0.6884, 0.7355, 0.7799, 0.8217, 0.8606, 0.8964,
|
||||
0.9288, 0.9570, 0.9803, 0.9958, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0308, 0.0725, 0.1225, 0.1737, 0.2320, 0.2960, 0.3641, 0.4393, 0.5133, 0.5825, 0.6460, 0.7032, 0.7544, 0.8003, 0.8426, 0.8817, 0.9168, 0.9475,
|
||||
0.9733, 0.9924, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0322, 0.0751, 0.1303, 0.1918, 0.2594, 0.3348, 0.4189, 0.5044, 0.5841, 0.6565, 0.7208, 0.7771, 0.8262, 0.8692, 0.9074, 0.9408, 0.9686, 0.9900,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0331, 0.0818, 0.1482, 0.2171, 0.2977, 0.3892, 0.4865, 0.5779, 0.6602, 0.7322, 0.7943, 0.8470, 0.8915, 0.9286, 0.9600, 0.9844, 0.9996, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0340, 0.0884, 0.1661, 0.2544, 0.3540, 0.4658, 0.5729, 0.6681, 0.7496, 0.8183, 0.8748, 0.9208, 0.9568, 0.9832, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.3356, 0.5799, 0.6166, 0.6166, 0.6166, 0.6166, 0.6624, 0.7556, 0.8321, 0.8935, 0.9411, 0.9755, 0.9970, 0.9998, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.5007, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.1655, 0.1655, 0.2972, 0.4728, 0.6327, 0.7616, 0.8598, 0.9317, 0.9790, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0457, 0.1830, 0.3836, 0.5790, 0.7379, 0.8549, 0.9360, 0.9869, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0520, 0.2329, 0.4864, 0.6989, 0.8449, 0.9403, 0.9884, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0773, 0.3027, 0.6040, 0.8058, 0.9302, 0.9930, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.1454, 0.4738, 0.7615, 0.9074, 0.9908, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0964, 0.5416, 0.8589, 0.9897, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.1538, 0.1538, 0.7080, 0.9596, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.8439, 0.8439, 0.8439, 0.9993, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.9228, 0.9228, 0.9228, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
];
|
||||
/// Blacks curves at internal amounts -100, …, 100 in steps of 10.
|
||||
#[allow(clippy::approx_constant)]
|
||||
static BLACKS: [[f32; 33]; 21] = [
|
||||
[
|
||||
0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0290, 0.0572, 0.0879, 0.1222, 0.1580, 0.1935, 0.2287, 0.2636, 0.2988, 0.3355, 0.3741, 0.4151, 0.4571,
|
||||
0.4990, 0.5406, 0.5818, 0.6226, 0.6631, 0.7031, 0.7424, 0.7811, 0.8192, 0.8568, 0.8937, 0.9300, 0.9655, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0000, 0.0000, 0.0015, 0.0363, 0.0640, 0.0956, 0.1300, 0.1651, 0.2000, 0.2345, 0.2686, 0.3026, 0.3369, 0.3727, 0.4105, 0.4495, 0.4885,
|
||||
0.5272, 0.5657, 0.6039, 0.6419, 0.6796, 0.7169, 0.7538, 0.7903, 0.8264, 0.8621, 0.8974, 0.9322, 0.9664, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0000, 0.0000, 0.0346, 0.0625, 0.0934, 0.1271, 0.1616, 0.1959, 0.2298, 0.2635, 0.2969, 0.3301, 0.3638, 0.3990, 0.4357, 0.4727, 0.5094,
|
||||
0.5460, 0.5824, 0.6187, 0.6547, 0.6906, 0.7262, 0.7615, 0.7965, 0.8313, 0.8657, 0.8998, 0.9336, 0.9671, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0000, 0.0235, 0.0554, 0.0850, 0.1175, 0.1514, 0.1852, 0.2187, 0.2519, 0.2849, 0.3178, 0.3505, 0.3837, 0.4183, 0.4537, 0.4891, 0.5243,
|
||||
0.5594, 0.5944, 0.6292, 0.6639, 0.6985, 0.7329, 0.7671, 0.8010, 0.8347, 0.8683, 0.9016, 0.9347, 0.9676, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0020, 0.0438, 0.0724, 0.1035, 0.1365, 0.1699, 0.2030, 0.2359, 0.2686, 0.3011, 0.3336, 0.3659, 0.3986, 0.4327, 0.4669, 0.5011, 0.5352,
|
||||
0.5692, 0.6031, 0.6369, 0.6706, 0.7043, 0.7378, 0.7711, 0.8043, 0.8373, 0.8702, 0.9029, 0.9355, 0.9679, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0230, 0.0567, 0.0865, 0.1182, 0.1510, 0.1839, 0.2164, 0.2489, 0.2812, 0.3133, 0.3455, 0.3775, 0.4100, 0.4433, 0.4767, 0.5100, 0.5433,
|
||||
0.5765, 0.6096, 0.6426, 0.6756, 0.7086, 0.7414, 0.7741, 0.8067, 0.8392, 0.8716, 0.9039, 0.9361, 0.9682, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0368, 0.0670, 0.0976, 0.1294, 0.1619, 0.1943, 0.2265, 0.2586, 0.2906, 0.3225, 0.3544, 0.3862, 0.4184, 0.4512, 0.4839, 0.5166, 0.5492,
|
||||
0.5818, 0.6144, 0.6469, 0.6793, 0.7117, 0.7441, 0.7763, 0.8085, 0.8406, 0.8727, 0.9046, 0.9365, 0.9683, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0444, 0.0749, 0.1058, 0.1377, 0.1699, 0.2020, 0.2339, 0.2657, 0.2975, 0.3292, 0.3609, 0.3926, 0.4245, 0.4568, 0.4891, 0.5213, 0.5535,
|
||||
0.5857, 0.6179, 0.6499, 0.6820, 0.7140, 0.7460, 0.7779, 0.8098, 0.8416, 0.8734, 0.9052, 0.9368, 0.9685, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0086, 0.0511, 0.0817, 0.1128, 0.1445, 0.1763, 0.2080, 0.2397, 0.2713, 0.3029, 0.3344, 0.3660, 0.3975, 0.4292, 0.4611, 0.4930, 0.5249, 0.5567,
|
||||
0.5886, 0.6204, 0.6522, 0.6839, 0.7157, 0.7475, 0.7791, 0.8108, 0.8424, 0.8740, 0.9056, 0.9371, 0.9686, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0198, 0.0564, 0.0874, 0.1186, 0.1501, 0.1817, 0.2133, 0.2447, 0.2762, 0.3076, 0.3390, 0.3705, 0.4018, 0.4333, 0.4649, 0.4965, 0.5281, 0.5597,
|
||||
0.5912, 0.6227, 0.6543, 0.6858, 0.7173, 0.7488, 0.7802, 0.8117, 0.8431, 0.8745, 0.9059, 0.9373, 0.9686, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625,
|
||||
0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0313, 0.0650, 0.0979, 0.1303, 0.1624, 0.1942, 0.2260, 0.2576, 0.2891, 0.3206, 0.3520, 0.3833, 0.4146, 0.4455, 0.4763, 0.5070, 0.5378, 0.5684,
|
||||
0.5991, 0.6298, 0.6605, 0.6912, 0.7219, 0.7526, 0.7833, 0.8141, 0.8450, 0.8758, 0.9067, 0.9377, 0.9688, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0326, 0.0677, 0.1022, 0.1357, 0.1686, 0.2010, 0.2332, 0.2652, 0.2969, 0.3286, 0.3601, 0.3915, 0.4227, 0.4532, 0.4835, 0.5138, 0.5440, 0.5741,
|
||||
0.6042, 0.6344, 0.6645, 0.6946, 0.7248, 0.7550, 0.7853, 0.8156, 0.8461, 0.8766, 0.9072, 0.9380, 0.9689, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0341, 0.0706, 0.1066, 0.1413, 0.1749, 0.2079, 0.2405, 0.2728, 0.3048, 0.3366, 0.3682, 0.3996, 0.4307, 0.4608, 0.4906, 0.5203, 0.5500, 0.5796,
|
||||
0.6092, 0.6387, 0.6683, 0.6979, 0.7276, 0.7573, 0.7871, 0.8170, 0.8472, 0.8773, 0.9077, 0.9382, 0.9690, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0354, 0.0736, 0.1113, 0.1472, 0.1814, 0.2149, 0.2479, 0.2805, 0.3127, 0.3446, 0.3763, 0.4077, 0.4385, 0.4681, 0.4974, 0.5267, 0.5558, 0.5849,
|
||||
0.6139, 0.6429, 0.6720, 0.7011, 0.7303, 0.7595, 0.7889, 0.8184, 0.8481, 0.8780, 0.9081, 0.9384, 0.9690, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0368, 0.0768, 0.1162, 0.1531, 0.1881, 0.2221, 0.2554, 0.2882, 0.3206, 0.3527, 0.3843, 0.4158, 0.4461, 0.4753, 0.5041, 0.5328, 0.5615, 0.5900,
|
||||
0.6185, 0.6470, 0.6755, 0.7041, 0.7328, 0.7616, 0.7906, 0.8197, 0.8491, 0.8787, 0.9085, 0.9386, 0.9691, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0381, 0.0802, 0.1214, 0.1593, 0.1949, 0.2293, 0.2630, 0.2961, 0.3286, 0.3607, 0.3924, 0.4236, 0.4536, 0.4823, 0.5106, 0.5388, 0.5669, 0.5949,
|
||||
0.6229, 0.6509, 0.6789, 0.7070, 0.7352, 0.7636, 0.7922, 0.8209, 0.8500, 0.8793, 0.9089, 0.9388, 0.9691, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0394, 0.0837, 0.1267, 0.1656, 0.2018, 0.2367, 0.2707, 0.3039, 0.3365, 0.3686, 0.4003, 0.4314, 0.4608, 0.4890, 0.5168, 0.5445, 0.5721, 0.5996,
|
||||
0.6271, 0.6546, 0.6822, 0.7098, 0.7375, 0.7655, 0.7937, 0.8221, 0.8509, 0.8799, 0.9092, 0.9390, 0.9692, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0407, 0.0875, 0.1322, 0.1720, 0.2088, 0.2441, 0.2784, 0.3118, 0.3444, 0.3765, 0.4082, 0.4389, 0.4679, 0.4955, 0.5229, 0.5501, 0.5771, 0.6042,
|
||||
0.6312, 0.6582, 0.6853, 0.7124, 0.7398, 0.7673, 0.7952, 0.8233, 0.8517, 0.8804, 0.9096, 0.9391, 0.9692, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0421, 0.0915, 0.1379, 0.1786, 0.2160, 0.2516, 0.2861, 0.3196, 0.3523, 0.3844, 0.4159, 0.4462, 0.4747, 0.5019, 0.5287, 0.5554, 0.5820, 0.6085,
|
||||
0.6351, 0.6616, 0.6882, 0.7149, 0.7419, 0.7690, 0.7965, 0.8243, 0.8524, 0.8810, 0.9099, 0.9393, 0.9693, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0436, 0.0957, 0.1438, 0.1853, 0.2232, 0.2592, 0.2938, 0.3274, 0.3601, 0.3922, 0.4235, 0.4533, 0.4813, 0.5080, 0.5343, 0.5605, 0.5866, 0.6127,
|
||||
0.6387, 0.6648, 0.6910, 0.7173, 0.7439, 0.7707, 0.7978, 0.8253, 0.8532, 0.8815, 0.9102, 0.9394, 0.9693, 1.0000,
|
||||
],
|
||||
];
|
||||
const BPS: [f32; 9] = [0.0000, 0.0200, 0.0500, 0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000];
|
||||
const WPS: [f32; 8] = [0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000, 0.9000, 1.0000];
|
||||
static AW: [[f32; 8]; 9] = [
|
||||
[1.7750, 2.3875, 2.4000, 2.1625, 1.9438, 1.6125, 1.3875, 1.0000],
|
||||
[1.8125, 2.5250, 2.3937, 2.1688, 1.9375, 1.6062, 1.3938, 0.9875],
|
||||
[2.7000, 2.4688, 2.2750, 2.0688, 1.8875, 1.6000, 1.3875, 0.9938],
|
||||
[2.3250, 2.3250, 2.1875, 1.9750, 1.7937, 1.5188, 1.3250, 1.0437],
|
||||
[2.0187, 2.0187, 2.0187, 1.8625, 1.7063, 1.4062, 1.2250, 0.9250],
|
||||
[1.7500, 1.7500, 1.7500, 1.7500, 1.6438, 1.3562, 1.1687, 0.8625],
|
||||
[1.4937, 1.4937, 1.4937, 1.4937, 1.4937, 1.2875, 1.1000, 0.7875],
|
||||
[1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.0375, 0.7562],
|
||||
[0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.7562],
|
||||
];
|
||||
static AB: [[f32; 8]; 9] = [
|
||||
[0.9938, 0.9812, 1.0000, 1.0125, 0.9563, 0.9688, 1.0437, 1.0000],
|
||||
[1.0437, 1.0250, 1.0125, 1.0000, 1.0000, 1.0375, 1.0000, 0.9938],
|
||||
[1.2312, 1.2063, 1.1875, 1.1625, 1.1438, 1.1438, 1.1375, 1.1250],
|
||||
[1.2500, 1.2500, 1.2312, 1.2125, 1.2125, 1.2375, 1.2375, 1.2375],
|
||||
[1.1750, 1.1750, 1.1750, 1.1750, 1.2063, 1.2688, 1.3125, 1.3375],
|
||||
[1.1812, 1.1812, 1.1812, 1.1812, 1.2125, 1.2812, 1.3313, 1.4125],
|
||||
[1.2375, 1.2375, 1.2375, 1.2375, 1.2375, 1.3062, 1.3562, 1.4375],
|
||||
[1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3687, 1.4500],
|
||||
[1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.4312],
|
||||
];
|
||||
|
||||
/// A curve row at a fractional internal amount `x`, at grey level `v` (both interpolated).
|
||||
fn curve(rows: &[[f32; STEPS]], from: f32, x: f32, v: f32) -> f32 {
|
||||
if v >= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
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;
|
||||
let at = |row: &[f32; STEPS]| {
|
||||
let p = v.max(0.0) * (STEPS - 1) as f32;
|
||||
let j = (p as usize).min(STEPS - 2);
|
||||
let s = p - j as f32;
|
||||
row[j] + (row[j + 1] - row[j]) * s
|
||||
};
|
||||
at(&rows[i]) * (1.0 - t) + at(&rows[i + 1]) * t
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Blacks at internal amount `x` (−100 … 100) of grey level `v`.
|
||||
pub(crate) fn blacks(v: f32, x: f32) -> f32 {
|
||||
curve(&BLACKS, BLACKS_FROM, x, v)
|
||||
}
|
||||
|
||||
/// The least Blacks adaptation on photographs.
|
||||
const PHOTO_BLACKS: f32 = 1.2;
|
||||
|
||||
/// Bilinear lookup of an adaptation table at black point `bp` and white point `wp`.
|
||||
fn adapt(table: &[[f32; 8]; 9], bp: f32, wp: f32) -> f32 {
|
||||
let idx = |grid: &[f32], v: f32| {
|
||||
let v = v.clamp(grid[0], grid[grid.len() - 1]);
|
||||
let i = grid.windows(2).position(|w| v <= w[1]).unwrap_or(grid.len() - 2);
|
||||
(i, (v - grid[i]) / (grid[i + 1] - grid[i]))
|
||||
};
|
||||
let (i, s) = idx(&BPS, bp);
|
||||
let (j, t) = idx(&WPS, wp);
|
||||
let row = |i: usize| table[i][j] * (1.0 - t) + table[i][j + 1] * t;
|
||||
row(i) * (1.0 - s) + row(i + 1) * s
|
||||
}
|
||||
|
||||
/// The internal amounts of the Whites and Blacks sliders (−100 … 100) for an image whose 1st
|
||||
/// and 99th luminance percentiles are the grey levels `bp` and `wp` (sRGB-encoded).
|
||||
pub(crate) fn amounts(whites: f32, blacks: f32, bp: f32, wp: f32) -> (f32, f32) {
|
||||
let w = if whites > 0.0 { whites * adapt(&AW, bp, wp) } else { whites };
|
||||
// On photographs Photoshop's Blacks is about 1.2 times the block images' amount even where
|
||||
// their black point is 0 (ten public-domain photos: 1.08–1.36).
|
||||
let b = if blacks < 0.0 { blacks * adapt(&AB, bp, wp).max(PHOTO_BLACKS) } else { blacks };
|
||||
(w.clamp(-100.0, 270.0), b.clamp(-100.0, 100.0))
|
||||
}
|
||||
|
||||
/// Applies Whites and Blacks (internal amounts `w`, `b`) to straight RGBA pixels (`width` per
|
||||
/// row) encoded with `enc`. Values above 1 (float documents) keep their excess.
|
||||
pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Encoding) {
|
||||
if w == 0.0 && b == 0.0 {
|
||||
return;
|
||||
}
|
||||
// A grey level's new level, both sRGB-encoded.
|
||||
let grey = |v: f32| {
|
||||
let v = if w != 0.0 { whites(v, w) } else { v };
|
||||
if b != 0.0 { blacks(v, b) } else { v }
|
||||
};
|
||||
let per_channel = w > 0.0 || b < 0.0;
|
||||
par_rows(px, width, 1, |_, row| {
|
||||
for q in row.iter_mut() {
|
||||
let lin = [0, 1, 2].map(|c| enc.decode(q[c]));
|
||||
if per_channel {
|
||||
for c in 0..3 {
|
||||
let over = (lin[c] - 1.0).max(0.0);
|
||||
q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over);
|
||||
}
|
||||
} else {
|
||||
let y = super::luma_linear(lin).max(0.0);
|
||||
if y <= 1e-7 {
|
||||
continue;
|
||||
}
|
||||
let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0);
|
||||
let k = y2 / y;
|
||||
for c in 0..3 {
|
||||
q[c] = enc.encode(lin[c] * k);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn full_range_rows_match_photoshop() {
|
||||
// Photoshop 25.4 on a 64-level block image spanning black to white (sRGB levels).
|
||||
assert!((whites(97.1 / 255.0, 100.0) * 255.0 - 137.4).abs() < 0.6);
|
||||
assert!((whites(194.3 / 255.0, -100.0) * 255.0 - 167.6).abs() < 0.6);
|
||||
assert!((blacks(97.1 / 255.0, -100.0) * 255.0 - 60.0).abs() < 0.6);
|
||||
assert!((blacks(48.6 / 255.0, 100.0) * 255.0 - 67.0).abs() < 0.6);
|
||||
// The compressing directions keep black and white.
|
||||
assert_eq!(whites(1.0, -100.0), 1.0);
|
||||
assert_eq!(blacks(0.0, 100.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clipping_directions_adapt_to_the_image() {
|
||||
// A dark, low-contrast image needs more: Whites +50 acts like +94.5 on [0.05, 0.6].
|
||||
let (w, _) = amounts(50.0, 0.0, 0.05, 0.6);
|
||||
assert!((w - 94.5).abs() < 2.0, "{w}");
|
||||
// A raised black point strengthens Blacks −40 to −58 on [0.6, 1].
|
||||
let (_, b) = amounts(0.0, -40.0, 0.6, 1.0);
|
||||
assert!((b + 58.0).abs() < 1.0, "{b}");
|
||||
// Never below the photographs' 1.2.
|
||||
assert!((amounts(0.0, -50.0, 0.0, 1.0).1 + 60.0).abs() < 1e-3);
|
||||
// The compressing directions never adapt.
|
||||
assert_eq!(amounts(-50.0, 50.0, 0.3, 0.6), (-50.0, 50.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -1095,7 +1095,7 @@ fn apply_tiled_with_impl(
|
|||
return (*t, Vec::new());
|
||||
};
|
||||
if let Some(sel) = selection {
|
||||
mix_selection(&mut data, *t, sel, src);
|
||||
mix_selection(&mut data, *t, sel, src, fmt.alpha);
|
||||
}
|
||||
tick();
|
||||
(*t, data)
|
||||
|
|
@ -1179,7 +1179,7 @@ fn apply_box_blur(
|
|||
let write = |(s, v): &mut (Surface, Vec<blur::Strip>)| {
|
||||
for (t, data) in v.iter_mut() {
|
||||
if let Some(sel) = selection {
|
||||
mix_selection(data, *t, sel, &Image::read(surface, *t));
|
||||
mix_selection(data, *t, sel, &Image::read(surface, *t), fmt.alpha);
|
||||
}
|
||||
s.write_region(*t, data);
|
||||
}
|
||||
|
|
@ -1202,8 +1202,9 @@ fn apply_box_blur(
|
|||
}
|
||||
|
||||
/// Mixes filtered `data` for `t` back toward the original pixels in `orig` by the selection's
|
||||
/// coverage (channel 0 of `sel`).
|
||||
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
|
||||
/// coverage (channel 0 of `sel`). Changing alpha requires premultiplied colour interpolation;
|
||||
/// otherwise hidden colours leak into feathered edges and alpha-removing filters darken them.
|
||||
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image, alpha: bool) {
|
||||
let w = t.width() as usize;
|
||||
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
|
||||
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
|
||||
|
|
@ -1211,6 +1212,27 @@ fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
|
|||
if k >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
if k <= 0.0 {
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
*v = orig.get(x, y, c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if alpha {
|
||||
let a = px.len() - 1;
|
||||
let original_alpha = orig.get(x, y, a);
|
||||
let filtered_alpha = px[a];
|
||||
if original_alpha != filtered_alpha {
|
||||
let mixed_alpha = original_alpha + (filtered_alpha - original_alpha) * k;
|
||||
let original_weight = original_alpha * (1.0 - k);
|
||||
let filtered_weight = filtered_alpha * k;
|
||||
for (c, v) in px[..a].iter_mut().enumerate() {
|
||||
*v = if mixed_alpha > 0.0 { (orig.get(x, y, c) * original_weight + *v * filtered_weight) / mixed_alpha } else { 0.0 };
|
||||
}
|
||||
px[a] = mixed_alpha;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
let o = orig.get(x, y, c);
|
||||
*v = o + (*v - o) * k;
|
||||
|
|
@ -1229,6 +1251,9 @@ mod tests_ext;
|
|||
#[cfg(test)]
|
||||
mod tests_mosaic;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_selection_mix;
|
||||
|
||||
/// The current upstream sampler through the identical source/selection/tile pipeline.
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ impl LiquifyStroke {
|
|||
}
|
||||
|
||||
/// The displacement field plus the freeze mask, over `bounds` (document pixels).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LiquifyField {
|
||||
pub bounds: Rect,
|
||||
/// Document pixels per field node.
|
||||
|
|
@ -165,6 +165,38 @@ pub struct LiquifyField {
|
|||
pub d: Vec<[f32; 2]>,
|
||||
/// Freeze mask per node (0 = free, 1 = frozen).
|
||||
pub freeze: Vec<f32>,
|
||||
/// Where the brush stroke in progress stands, so dab spacing runs on across its segments.
|
||||
cursor: Option<StrokeCursor>,
|
||||
}
|
||||
|
||||
/// Two fields are equal when their displacement and freeze mask are: the stroke cursor is
|
||||
/// transient brush state, not part of the result.
|
||||
impl PartialEq for LiquifyField {
|
||||
fn eq(&self, o: &Self) -> bool {
|
||||
self.bounds == o.bounds && self.cell == o.cell && self.w == o.w && self.h == o.h && self.d == o.d && self.freeze == o.freeze
|
||||
}
|
||||
}
|
||||
|
||||
/// The brush stroke in progress: the point its last segment ended at, its last dab and how far
|
||||
/// the brush has travelled along the path since that dab.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct StrokeCursor {
|
||||
end: [f64; 2],
|
||||
last_dab: [f64; 2],
|
||||
carry: f64,
|
||||
}
|
||||
|
||||
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
|
||||
/// drag is many small pushes and its edge stays smooth.
|
||||
fn dab_spacing(r: f64) -> f64 {
|
||||
(r * 0.05).max(0.5)
|
||||
}
|
||||
|
||||
/// Per-dab strength of a stationary tool (Twirl, Pucker, Bloat, Reconstruct, Smooth, Freeze,
|
||||
/// Thaw) while it moves: scaled to the spacing so its effect per distance travelled stays what it
|
||||
/// was with dabs a fifth of the radius apart.
|
||||
fn moving_weight(r: f64) -> f64 {
|
||||
dab_spacing(r) / (r * 0.2).max(0.5)
|
||||
}
|
||||
|
||||
/// The default field resolution for `bounds`: 2 px per node up to 4 MP, 4 px beyond.
|
||||
|
|
@ -177,7 +209,7 @@ impl LiquifyField {
|
|||
let cell = cell.clamp(1.0, 64.0);
|
||||
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
|
||||
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
|
||||
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h] }
|
||||
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
|
||||
}
|
||||
|
||||
/// Document position of node (i, j) (node 0 sits on the first pixel centre).
|
||||
|
|
@ -220,6 +252,7 @@ impl LiquifyField {
|
|||
|
||||
/// Applies one stroke. Returns the document rectangle whose output changed.
|
||||
pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect {
|
||||
self.cursor = None;
|
||||
match s.tool {
|
||||
LiquifyTool::LassoMask => return self.apply_lasso(s),
|
||||
LiquifyTool::ReconstructAll => {
|
||||
|
|
@ -306,77 +339,120 @@ impl LiquifyField {
|
|||
/// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what
|
||||
/// [`Self::apply_stroke`] does with the whole point list.
|
||||
pub fn stroke_begin(&mut self, s: &LiquifyStroke, p: [f64; 3]) -> Rect {
|
||||
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0)) } else { Rect::EMPTY }
|
||||
self.cursor = Some(StrokeCursor { end: [p[0], p[1]], last_dab: [p[0], p[1]], carry: 0.0 });
|
||||
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0), 1.0) } else { Rect::EMPTY }
|
||||
}
|
||||
|
||||
/// Continues a stroke from `a` to `b`: dabs every fifth of the brush radius (a stationary
|
||||
/// tool held still dabs once at `b`).
|
||||
/// Continues a stroke from `a` to `b`. Dabs fall every [`dab_spacing`] of distance travelled
|
||||
/// along the whole stroke, carried from one segment to the next, so the result is the same
|
||||
/// however finely the pointer path was sampled (a fast and a slow mouse give one warp). A
|
||||
/// stationary tool held still dabs once at `b`. When `a` is not where the previous segment
|
||||
/// ended, the stroke starts afresh at `a`.
|
||||
pub fn stroke_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
|
||||
self.walk_segment(s, a, b, true)
|
||||
}
|
||||
|
||||
/// [`Self::stroke_segment`]; `skip` leaves out the dabs that cannot reach a field node (they
|
||||
/// would change nothing), which tests compare with the full walk.
|
||||
fn walk_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3], skip: bool) -> Rect {
|
||||
let mut dirty = Rect::EMPTY;
|
||||
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
|
||||
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
|
||||
let stationary = s.tool.is_stationary();
|
||||
let cur = match self.cursor {
|
||||
Some(c) if c.end == [a[0], a[1]] => c,
|
||||
_ => StrokeCursor { end: [a[0], a[1]], last_dab: [a[0], a[1]], carry: 0.0 },
|
||||
};
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let len = dx.hypot(dy);
|
||||
if len == 0.0 {
|
||||
return if s.tool.is_stationary() { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb) } else { dirty };
|
||||
self.cursor = Some(cur);
|
||||
return if stationary { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb, 1.0) } else { dirty };
|
||||
}
|
||||
// NaN or a length that overflowed: nothing sensible to walk; the next segment restarts.
|
||||
if !len.is_finite() {
|
||||
self.cursor = None;
|
||||
return dirty;
|
||||
}
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let spacing = (r * 0.2).max(0.5);
|
||||
let steps = (len / spacing).ceil().max(1.0) as usize;
|
||||
// Walk only the steps whose dab can reach a field node: the rest return from `dab` before
|
||||
// touching anything, and a segment to a far-off point has trillions of them (#937).
|
||||
let Some((k0, k1)) = self.reachable_steps(a, b, steps, r, s.tool.is_stationary()) else { return dirty };
|
||||
for k in k0..=k1 {
|
||||
let t0 = (k - 1) as f64 / steps as f64;
|
||||
let t1 = k as f64 / steps as f64;
|
||||
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
|
||||
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
|
||||
let pr = pa + (pb - pa) * t1;
|
||||
// Forward Warp centres the dab where the brush was (it drags what is under it).
|
||||
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
|
||||
dirty = dirty.union(&self.dab(s, centre, delta, pr));
|
||||
let spacing = dab_spacing(r);
|
||||
let weight = if stationary { moving_weight(r) } else { 1.0 };
|
||||
// Distance along this segment of its first dab, then one every `spacing`.
|
||||
let first = (spacing - cur.carry).clamp(0.0, spacing);
|
||||
// Distances are compared with a little slack: a dab that rounding puts a hair past the
|
||||
// segment's end still falls on it, so cutting one segment in two never adds or drops a dab.
|
||||
let slack = spacing * 1e-6;
|
||||
if first > len + slack {
|
||||
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: cur.last_dab, carry: cur.carry + len });
|
||||
return dirty;
|
||||
}
|
||||
let last_k = ((len - first + slack) / spacing).floor().max(0.0);
|
||||
let at = |dist: f64| [a[0] + dx * dist / len, a[1] + dy * dist / len];
|
||||
let (mut k0, mut k1) = (0.0f64, last_k);
|
||||
if skip {
|
||||
match self.reachable_span(a, [dx, dy], len, r) {
|
||||
// A dragging dab is centred one spacing behind its position: pad by that and a step.
|
||||
Some((d0, d1)) => {
|
||||
k0 = k0.max(((d0 - spacing - first) / spacing).floor() - 1.0);
|
||||
k1 = k1.min(((d1 + spacing - first) / spacing).ceil() + 1.0);
|
||||
}
|
||||
None => k1 = -1.0,
|
||||
}
|
||||
}
|
||||
if k0 <= k1 {
|
||||
let mut prev = if k0 > 0.0 { at(first + (k0 - 1.0) * spacing) } else { cur.last_dab };
|
||||
for k in (k0 as u64)..=(k1 as u64) {
|
||||
let dist = (first + k as f64 * spacing).min(len);
|
||||
let c = at(dist);
|
||||
let delta = [c[0] - prev[0], c[1] - prev[1]];
|
||||
// Forward Warp centres the dab where the brush was (it drags what is under it).
|
||||
let centre = if stationary { c } else { prev };
|
||||
dirty = dirty.union(&self.dab(s, centre, delta, pa + (pb - pa) * dist / len, weight));
|
||||
prev = c;
|
||||
}
|
||||
}
|
||||
let last = (first + last_k * spacing).min(len);
|
||||
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: at(last), carry: (len - last).clamp(0.0, spacing) });
|
||||
dirty
|
||||
}
|
||||
|
||||
/// The steps `k0..=k1` of a segment walked in `steps` whose dab can touch a field node, or
|
||||
/// `None` when none can. Step `k` centres its dab at `a + (b - a)·(k - o)/steps`, with `o` = 1
|
||||
/// for the dragging tools and 0 for the stationary ones (see `stroke_segment`). A dab reaches a
|
||||
/// node only when its centre is within `r` plus a node of the node grid, so the window below
|
||||
/// pads that by another node and a step: the dabs it leaves out would each change nothing.
|
||||
fn reachable_steps(&self, a: [f64; 3], b: [f64; 3], steps: usize, r: f64, stationary: bool) -> Option<(usize, usize)> {
|
||||
/// The distances along a segment from `a` (direction `dir`, length `len`) whose points lie
|
||||
/// within a dab radius `r` plus two nodes of the node grid, or `None` when none do. Only dabs
|
||||
/// near that span can touch a node; a segment to a far-off point has trillions of others
|
||||
/// (#937).
|
||||
fn reachable_span(&self, a: [f64; 3], dir: [f64; 2], len: f64, r: f64) -> Option<(f64, f64)> {
|
||||
if self.w == 0 || self.h == 0 {
|
||||
return None;
|
||||
}
|
||||
let pad = r + 2.0 * self.cell;
|
||||
let lo = [f64::from(self.bounds.x0) + 0.5 - pad, f64::from(self.bounds.y0) + 0.5 - pad];
|
||||
let hi = [lo[0] + (self.w - 1) as f64 * self.cell + 2.0 * pad, lo[1] + (self.h - 1) as f64 * self.cell + 2.0 * pad];
|
||||
let (n, o) = (steps as f64, if stationary { 0.0 } else { 1.0 });
|
||||
let (mut k0, mut k1) = (1.0f64, n);
|
||||
let (mut u0, mut u1) = (0.0f64, 1.0f64);
|
||||
for ax in 0..2 {
|
||||
let d = b[ax] - a[ax];
|
||||
let d = dir[ax];
|
||||
if d == 0.0 {
|
||||
if a[ax].is_nan() || a[ax] < lo[ax] || a[ax] > hi[ax] {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (u0, u1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
|
||||
k0 = k0.max((u0.min(u1) * n + o).floor() - 1.0);
|
||||
k1 = k1.min((u0.max(u1) * n + o).ceil() + 1.0);
|
||||
let (v0, v1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
|
||||
u0 = u0.max(v0.min(v1));
|
||||
u1 = u1.min(v0.max(v1));
|
||||
}
|
||||
// NaN: a segment whose length overflowed.
|
||||
if k0.is_nan() || k1.is_nan() || k0 > k1 {
|
||||
if u0.is_nan() || u1.is_nan() || u0 > u1 {
|
||||
return None;
|
||||
}
|
||||
Some((k0 as usize, k1 as usize))
|
||||
Some((u0 * len, u1 * len))
|
||||
}
|
||||
|
||||
/// One dab at `c` with brush motion `delta`. Returns the document rect it affected.
|
||||
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect {
|
||||
/// One dab at `c` with brush motion `delta`, its strength scaled by `weight`. Returns the
|
||||
/// document rect it affected.
|
||||
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64, weight: f64) -> Rect {
|
||||
if s.tool == LiquifyTool::LassoMask {
|
||||
return Rect::EMPTY; // the lasso is polygon-only; never a brush dab
|
||||
}
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure;
|
||||
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure * weight;
|
||||
let rate = (s.rate / 100.0).clamp(0.0, 1.0);
|
||||
let density = (s.density / 100.0).clamp(0.0, 1.0);
|
||||
if strength <= 0.0 {
|
||||
|
|
@ -921,21 +997,9 @@ mod tests {
|
|||
use super::*;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
|
||||
/// The segment walk before #937: every step, whether or not its dab can reach the field.
|
||||
/// The segment walk before #937: every dab, whether or not it can reach the field.
|
||||
fn full_walk(f: &mut LiquifyField, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
|
||||
let mut dirty = Rect::EMPTY;
|
||||
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
|
||||
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let steps = (len / (r * 0.2).max(0.5)).ceil().max(1.0) as usize;
|
||||
for k in 1..=steps {
|
||||
let (t0, t1) = ((k - 1) as f64 / steps as f64, k as f64 / steps as f64);
|
||||
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
|
||||
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
|
||||
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
|
||||
dirty = dirty.union(&f.dab(s, centre, delta, pa + (pb - pa) * t1));
|
||||
}
|
||||
dirty
|
||||
f.walk_segment(s, a, b, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -945,7 +1009,7 @@ mod tests {
|
|||
// every result bit-identical to the full walk.
|
||||
use LiquifyTool::*;
|
||||
let bounds = Rect::new(-7, 3, 89, 67);
|
||||
let segments: [([f64; 3], [f64; 3]); 9] = [
|
||||
let segments: [([f64; 3], [f64; 3]); 10] = [
|
||||
([10.0, 20.0, 1.0], [70.0, 50.0, 0.5]), // inside
|
||||
([-900.0, 30.0, 1.0], [40.0, 31.0, 1.0]), // in from far left
|
||||
([40.0, 30.0, 1.0], [40.0, 4000.0, 0.2]), // out downwards
|
||||
|
|
@ -955,6 +1019,7 @@ mod tests {
|
|||
([88.5, -50.0, 1.0], [88.5, 120.0, 1.0]), // vertical, along the right edge
|
||||
([200.0, 200.0, 1.0], [201.0, 900.0, 1.0]), // entirely outside
|
||||
([0.0, 0.0, 1.0], [0.25, 0.1, 1.0]), // shorter than one step
|
||||
([60.0, 40.0, 1.0], [-2000.0, 45.0, 1.0]), // continues the stroke before it, out left
|
||||
];
|
||||
for tool in [ForwardWarp, PushLeft, Reconstruct, Smooth, TwirlCw, TwirlCcw, Pucker, Bloat, Freeze, Thaw] {
|
||||
for size in [6.0, 40.0] {
|
||||
|
|
@ -973,6 +1038,81 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// The same path sampled by `parts` pointer events per corner-to-corner leg, unevenly.
|
||||
fn sampled(path: &[[f64; 3]], parts: usize) -> Vec<Vec<f64>> {
|
||||
let mut out = vec![path[0].to_vec()];
|
||||
for w in path.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
// Uneven steps, as a real pointer gives: t grows quadratically.
|
||||
for k in 1..=parts {
|
||||
let t = (k * k) as f64 / (parts * parts) as f64;
|
||||
out.push((0..3).map(|i| a[i] + (b[i] - a[i]) * t).collect());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// #2606: dabs restarted at every pointer event, so a fast mouse (few far-apart events) and a
|
||||
/// slow one (many close ones) warped the same path differently. Spacing now runs on along the
|
||||
/// whole stroke: any sampling of a path gives the same field.
|
||||
#[test]
|
||||
fn the_result_does_not_depend_on_how_finely_the_path_was_sampled() {
|
||||
use LiquifyTool::*;
|
||||
let path = [[20.0, 20.0, 1.0], [62.0, 26.0, 0.6], [70.0, 50.0, 0.9], [28.0, 55.0, 1.0], [30.0, 30.0, 1.0]];
|
||||
for tool in [ForwardWarp, PushLeft, TwirlCw, Bloat, Reconstruct, Smooth, Freeze] {
|
||||
for size in [12.0, 40.0] {
|
||||
let field = |parts: usize| {
|
||||
let mut warp = LiquifyStroke::new(ForwardWarp, 30.0);
|
||||
warp.points = vec![vec![30.0, 30.0], vec![50.0, 40.0]];
|
||||
let mut s = LiquifyStroke::new(tool, size);
|
||||
s.points = sampled(&path, parts);
|
||||
// Some displacement first, so Reconstruct and Smooth have something to change.
|
||||
LiquifyField::from_strokes(bounds(), 2.0, &[warp, s])
|
||||
};
|
||||
let coarse = field(1);
|
||||
for parts in [3, 17, 60] {
|
||||
let fine = field(parts);
|
||||
let dd = coarse.d.iter().zip(&fine.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
|
||||
let df = coarse.freeze.iter().zip(&fine.freeze).map(|(p, q)| (p - q).abs()).fold(0.0f32, f32::max);
|
||||
assert!(dd < 1e-3 && df < 1e-4, "{tool:?} {size}px, {parts} events per leg: field differs by {dd} px, mask by {df}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A long drag whose events land exactly on dab positions, along a diagonal: rounding must not
|
||||
/// add or drop the dab at a segment's end, or the deep displacement a long Forward Warp builds
|
||||
/// up differs by a whole dab between a slow and a fast pointer.
|
||||
#[test]
|
||||
fn a_long_diagonal_drag_gives_one_field_at_any_event_spacing() {
|
||||
let field = |step: f64| {
|
||||
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
|
||||
s.points = (0..=(300.0 / step) as usize).map(|k| vec![40.0 + k as f64 * step * 0.6, 20.0 + k as f64 * step * 0.8]).collect();
|
||||
LiquifyField::from_strokes(Rect::new(0, 0, 320, 300), 2.0, &[s])
|
||||
};
|
||||
let (slow, fast) = (field(2.0), field(60.0));
|
||||
let dd = slow.d.iter().zip(&fast.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
|
||||
assert!(slow.max_displacement() > 50.0, "the drag builds up a deep displacement");
|
||||
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
|
||||
// A segment that does not start where the last one ended begins a new run of dabs there.
|
||||
let s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 20.0);
|
||||
let mut f = LiquifyField::new(bounds(), 2.0);
|
||||
f.stroke_begin(&s, [10.0, 10.0, 1.0]);
|
||||
f.stroke_segment(&s, [10.0, 10.0, 1.0], [10.3, 10.0, 1.0]);
|
||||
let mut g = LiquifyField::new(bounds(), 2.0);
|
||||
f.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
|
||||
g.stroke_begin(&s, [40.0, 30.0, 1.0]);
|
||||
g.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
|
||||
assert_eq!(f, g);
|
||||
// Hostile points: nothing panics and the next segment starts afresh.
|
||||
f.stroke_segment(&s, [f64::NAN, 3.0, 1.0], [4.0, f64::INFINITY, 1.0]);
|
||||
f.stroke_segment(&s, [4.0, 3.0, f64::NAN], [9.0, 3.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_to_a_far_off_point_returns() {
|
||||
// The issue's request: about 3.3e14 steps before the fix. Also a segment whose length
|
||||
|
|
@ -1110,8 +1250,8 @@ mod tests {
|
|||
/// dab's circle (a hard-edged block at Density 0, half strength at the default 50).
|
||||
#[test]
|
||||
fn forward_warp_leaves_the_brush_rim_in_place() {
|
||||
// A drag of one dab spacing (a fifth of the radius): one dab, centred where it started.
|
||||
let (r, drag) = (20.0, 4.0);
|
||||
// A drag of one dab spacing (a twentieth of the radius): one dab, centred where it started.
|
||||
let (r, drag) = (20.0, 1.0);
|
||||
for density in [0.0, 50.0, 100.0] {
|
||||
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 2.0 * r);
|
||||
s.density = density;
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ pub struct RefineParams {
|
|||
pub feather: f32,
|
||||
/// 0–100.
|
||||
pub contrast: f32,
|
||||
/// −100–100 (% of `max(radius, 4 px)`).
|
||||
/// −100–100. 100% moves the edge by `max(radius, 4)` pixels. Values between whole
|
||||
/// pixels blend the two neighbouring shifts, so the slider does not jump.
|
||||
pub shift_edge: f32,
|
||||
}
|
||||
|
||||
|
|
@ -59,8 +60,18 @@ impl RefineParams {
|
|||
fn feather_sigma(&self) -> f32 {
|
||||
self.feather.max(0.0) / 2.0
|
||||
}
|
||||
/// Signed shift in pixels, fractional. 100% is `max(radius, 4)` px, clamped to ±64.
|
||||
fn shift_amount(&self) -> f32 {
|
||||
let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0);
|
||||
if px.is_finite() { px.clamp(-64.0, 64.0) } else { 0.0 }
|
||||
}
|
||||
/// How many pixels the mask can move: the farther of the two integer radii a fractional
|
||||
/// shift blends. Whole-pixel amounts stay exact (`ceil(4) == 4`).
|
||||
fn shift_px(&self) -> i32 {
|
||||
(self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0)).round().clamp(-64.0, 64.0) as i32
|
||||
let px = self.shift_amount();
|
||||
let reach = px.abs().ceil();
|
||||
let signed = if px.is_sign_negative() { -reach } else { reach };
|
||||
if signed.is_finite() { signed as i32 } else { 0 }
|
||||
}
|
||||
fn uses_image(&self) -> bool {
|
||||
self.radius > 0.0
|
||||
|
|
@ -370,13 +381,38 @@ where
|
|||
*v = ((*v - 0.5) * gain + 0.5).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
let d = p.shift_px();
|
||||
if d != 0 {
|
||||
a = morphology(&a, w, h, d.unsigned_abs() as usize, d > 0);
|
||||
let amount = p.shift_amount();
|
||||
if amount.abs() >= 1.0e-4 {
|
||||
a = shift_mask(&a, w, h, amount, morphology);
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Grow or shrink `src` by a fractional pixel distance. Whole pixels use one morphology;
|
||||
/// the remainder blends that result with the next pixel, so 0.3 px is not the same step as 1 px.
|
||||
fn shift_mask<F>(src: &[f32], w: usize, h: usize, amount: f32, morphology: F) -> Vec<f32>
|
||||
where
|
||||
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
|
||||
{
|
||||
let mag = amount.abs();
|
||||
if !mag.is_finite() || mag < 1.0e-4 {
|
||||
return src.to_vec();
|
||||
}
|
||||
let grow = amount > 0.0;
|
||||
let whole = mag.floor() as usize;
|
||||
let frac = mag - whole as f32;
|
||||
if frac < 1.0e-4 {
|
||||
return morphology(src, w, h, whole, grow);
|
||||
}
|
||||
let lo = if whole == 0 { src.to_vec() } else { morphology(src, w, h, whole, grow) };
|
||||
let hi = morphology(src, w, h, whole + 1, grow);
|
||||
let n = w.saturating_mul(h);
|
||||
if lo.len() < n || hi.len() < n || lo.len() != hi.len() {
|
||||
return hi;
|
||||
}
|
||||
lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect()
|
||||
}
|
||||
|
||||
/// Refines a selection mask. `mask(r)` returns coverage over `r` (row-major); `content` bounds
|
||||
/// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image
|
||||
/// (only read when `radius > 0`). Returns `None` when nothing remains selected.
|
||||
|
|
@ -720,6 +756,16 @@ mod tests {
|
|||
let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() });
|
||||
assert_eq!(shrunk[5 * w + 27], 1.0);
|
||||
assert_eq!(shrunk[5 * w + 28], 0.0);
|
||||
// Radius 0 scales 100% to 4 px, so 12.5% is half a pixel and 25% is one pixel.
|
||||
// The pixel just outside a hard edge fades in instead of snapping on.
|
||||
let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 12.5, ..Default::default() });
|
||||
let one = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 25.0, ..Default::default() });
|
||||
assert_eq!(m[5 * w + 30], 0.0);
|
||||
assert!((half[5 * w + 30] - 0.5).abs() < 0.02, "half pixel {}", half[5 * w + 30]);
|
||||
assert_eq!(one[5 * w + 30], 1.0);
|
||||
assert_eq!(half[5 * w + 31], 0.0, "the blend must not jump a second pixel");
|
||||
let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -12.5, ..Default::default() });
|
||||
assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]);
|
||||
let sm = refine_buffer(None, &m, w, h, &RefineParams { smooth: 50.0, ..Default::default() });
|
||||
assert!(sm[5 * w + 10] == 1.0 && sm[5 * w + 50] == 0.0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ fn tiled_filters_match_scalar_at_all_depths_with_soft_selection() {
|
|||
FilterParams::ColorHalftone { max_radius, angles } => reference::halftone(&image, bounds, &ctx, max_radius, angles),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
crate::mix_selection(&mut expected_data, bounds, &selection, &image);
|
||||
crate::mix_selection(&mut expected_data, bounds, &selection, &image, ctx.alpha);
|
||||
let mut expected = surface.clone();
|
||||
expected.write_region(bounds, &expected_data);
|
||||
for tile in [1, 7, 17, 256] {
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ fn surfaces_exact_depth_models_alpha_selection_and_tile_boundaries() {
|
|||
};
|
||||
let spans = Spans::new(radius, shape_inside(shape));
|
||||
let mut expected = conv_spans(&src, area, &ctx, &spans);
|
||||
crate::mix_selection(&mut expected, area, &selection, &src);
|
||||
crate::mix_selection(&mut expected, area, &selection, &src, alpha);
|
||||
let mut reference = surface.clone();
|
||||
reference.write_region(area, &expected);
|
||||
for tile in [17, 256] {
|
||||
|
|
|
|||
|
|
@ -167,17 +167,7 @@ fn storage_selection_and_tile_sizes_match_original_kernels() {
|
|||
FilterParams::Wind { method, from_right, seed } => wind_direct(&src, area, &ctx, method, from_right, seed),
|
||||
_ => continue,
|
||||
};
|
||||
let original = s.read_region(area);
|
||||
for (i, px) in raw.chunks_exact_mut(fmt.channels()).enumerate() {
|
||||
let (x, y) = (area.x0 + (i % area.width() as usize) as i32, area.y0 + (i / area.width() as usize) as i32);
|
||||
let k = selection.sample_channel(x, y, 0);
|
||||
if k >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
*v = original[i * fmt.channels() + c] + (*v - original[i * fmt.channels() + c]) * k;
|
||||
}
|
||||
}
|
||||
crate::mix_selection(&mut raw, area, &selection, &src, fmt.alpha);
|
||||
let mut want = s.clone();
|
||||
want.write_region(area, &raw);
|
||||
for tile in [1, 7, 17, 256] {
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ fn iris_depths_models_selection_tiles_and_cancellation() {
|
|||
let t = Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1));
|
||||
let src = Image::read_clamped(&surface, t.inflate(radius), bounds);
|
||||
let mut data = reference::iris(&src, t, &ctx, &pins);
|
||||
crate::mix_selection(&mut data, t, &selection, &src);
|
||||
crate::mix_selection(&mut data, t, &selection, &src, alpha);
|
||||
want.write_region(t, &data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
use super::*;
|
||||
use photocraft_color::{PixelFormat, SampleType};
|
||||
|
||||
fn selection(coverage: f32) -> Surface {
|
||||
Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), &[coverage])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feathered_color_to_alpha_preserves_appearance_over_the_key() {
|
||||
let r = Rect::new(0, 0, 1, 1);
|
||||
let filter = FilterParams::ColorToAlpha { color: [1.0; 4], transparency_threshold: 0.0, opacity_threshold: 1.0 };
|
||||
for sample in SampleType::ALL {
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
|
||||
for alpha in [0.5, 1.0] {
|
||||
let format = PixelFormat::new(mode, sample, true);
|
||||
let mut s = Surface::new(format);
|
||||
let mut p = vec![0.5; format.channels()];
|
||||
*p.last_mut().unwrap() = alpha;
|
||||
s.write_pixel(0, 0, &p);
|
||||
let original = s.pixel(0, 0);
|
||||
let a = format.channels() - 1;
|
||||
for coverage in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
let out = apply(&s, &filter, r, r, Some(&selection(coverage)));
|
||||
let actual = out.pixel(0, 0);
|
||||
let tolerance = if sample == SampleType::U8 { 2.0 / 255.0 } else { 1e-4 };
|
||||
for c in 0..a {
|
||||
let before = original[c] * original[a] + 1.0 - original[a];
|
||||
let after = actual[c] * actual[a] + 1.0 - actual[a];
|
||||
assert!(
|
||||
(before - after).abs() < tolerance,
|
||||
"{sample:?}/{mode:?}, alpha={alpha}, coverage={coverage}: {original:?} -> {actual:?}, over white {before} -> {after}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feathered_offset_ignores_hidden_colour_of_the_original_pixel() {
|
||||
let r = Rect::new(0, 0, 2, 1);
|
||||
let filter = FilterParams::Offset { horizontal: 1, vertical: 0, undefined: UndefinedAreas::Wrap };
|
||||
for sample in SampleType::ALL {
|
||||
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
|
||||
s.write_pixel(0, 0, &[1.0, 0.0, 0.0, 0.0]);
|
||||
s.write_pixel(1, 0, &[0.0, 0.0, 1.0, 1.0]);
|
||||
let out = apply(&s, &filter, r, r, Some(&selection(0.5)));
|
||||
let p = out.pixel(0, 0);
|
||||
assert_eq!(&p[..3], &[0.0, 0.0, 1.0], "{sample:?}: hidden red contaminated the selected blue: {p:?}");
|
||||
assert!((p[3] - 0.5).abs() < 1.0 / 255.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feathered_blurs_do_not_reintroduce_hidden_colour() {
|
||||
let r = Rect::new(0, 0, 3, 3);
|
||||
let mut s = Surface::new(PixelFormat::RGBA32F);
|
||||
s.fill_rect(r, &[1.0, 0.0, 0.0, 0.0]);
|
||||
s.write_pixel(1, 1, &[0.0, 0.0, 1.0, 1.0]);
|
||||
for filter in [FilterParams::BoxBlur { radius: 1.0 }, FilterParams::MotionBlur { angle: 0.0, distance: 3.0 }] {
|
||||
let full = apply(&s, &filter, r, r, None);
|
||||
let out = apply(&s, &filter, r, r, Some(&selection(0.5)));
|
||||
let p = out.pixel(0, 1);
|
||||
assert!(p[3] > 0.0, "{filter:?}: expected blur to spread coverage");
|
||||
assert!((p[3] - full.pixel(0, 1)[3] * 0.5).abs() < 1e-6, "{filter:?}: selection should halve the new coverage");
|
||||
assert!(p[0].abs() < 1e-6 && (p[2] - 1.0).abs() < 1e-6, "{filter:?}: hidden red reappeared: {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_mixing_keeps_hdr_range_and_exact_endpoints() {
|
||||
let r = Rect::new(0, 0, 1, 1);
|
||||
let original = Image { rect: r, ch: 4, data: vec![-2.0, 4.0, 0.5, 0.25] };
|
||||
let filtered = vec![8.0, -4.0, 0.75, 1.0];
|
||||
let mut data = filtered.clone();
|
||||
mix_selection(&mut data, r, &selection(0.5), &original, true);
|
||||
for (&actual, expected) in data.iter().zip([6.0, -2.4, 0.7, 0.625]) {
|
||||
assert!((actual - expected).abs() < 1e-6, "{data:?}");
|
||||
}
|
||||
for coverage in [0.0, 1.0] {
|
||||
let mut data = filtered.clone();
|
||||
mix_selection(&mut data, r, &selection(coverage), &original, true);
|
||||
assert_eq!(data, if coverage == 0.0 { &original.data } else { &filtered }.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_mixing_without_alpha_keeps_every_channel_independent() {
|
||||
let r = Rect::new(0, 0, 1, 1);
|
||||
// One channel is a mask; three/four channels can be RGB or CMYK without alpha.
|
||||
for ch in [1, 3, 4] {
|
||||
let original = Image { rect: r, ch, data: vec![0.25; ch] };
|
||||
let mut data = vec![0.75; ch];
|
||||
mix_selection(&mut data, r, &selection(0.25), &original, false);
|
||||
assert_eq!(data, vec![0.375; ch]);
|
||||
}
|
||||
}
|
||||
|
|
@ -177,7 +177,7 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() {
|
|||
None => Image::read(&surface, t.inflate(radius)),
|
||||
};
|
||||
let mut data = reference::tilt_shift(&src, t, &ctx, 17.0, (0.3, 0.7), 29.0, 0.07, 0.3);
|
||||
crate::mix_selection(&mut data, t, &selection, &src);
|
||||
crate::mix_selection(&mut data, t, &selection, &src, alpha);
|
||||
want.write_region(t, &data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
//! All work on straight (non-premultiplied) RGBA buffers in 0..=1, row-major `w × h`, so callers
|
||||
//! convert any colour model / bit depth to RGBA first (as `Image › Adjustments` does for the
|
||||
//! per-pixel adjustments). Formulas are documented approximations of Photoshop's behaviour,
|
||||
//! derived from its manual and observation, not from its implementation.
|
||||
//! derived from its manual and observation, not from its implementation; Shadows/Highlights is
|
||||
//! measured on Photoshop (see [`shadows_highlights`]).
|
||||
|
||||
use photocraft_color::convert::{lab_to_srgb, srgb_to_lab};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -17,18 +18,6 @@ fn luma(c: [f32; 3]) -> f32 {
|
|||
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
|
||||
}
|
||||
|
||||
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// Blurred luminance (Gaussian, `radius` px ≈ 2σ like Photoshop's radius fields).
|
||||
fn blurred_luma(px: &[[f32; 4]], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
let mut l: Vec<f32> = px.iter().map(|p| luma([p[0], p[1], p[2]])).collect();
|
||||
gauss_blur_n(&mut l, w, h, 1, radius.max(0.0) / 2.0);
|
||||
l
|
||||
}
|
||||
|
||||
/// Image › Adjustments › Shadows/Highlights parameters (Photoshop's dialog, percentages as 0..=100).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ShadowsHighlights {
|
||||
|
|
@ -64,95 +53,8 @@ impl Default for ShadowsHighlights {
|
|||
}
|
||||
}
|
||||
|
||||
/// Local tone mapping: a blurred-luminance mask selects shadows (dark neighbourhoods, up to the
|
||||
/// tonal width) and highlights; shadows are lifted and highlights pulled down by a gain that
|
||||
/// preserves hue, then saturation is corrected where the gain moved tones, midtone contrast is
|
||||
/// applied, and the result is stretched by the clip percentages.
|
||||
pub fn shadows_highlights(px: &mut [[f32; 4]], w: usize, h: usize, p: &ShadowsHighlights) {
|
||||
if px.is_empty() {
|
||||
return;
|
||||
}
|
||||
let ls = blurred_luma(px, w, h, p.shadow_radius);
|
||||
let lh = if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 { ls.clone() } else { blurred_luma(px, w, h, p.highlight_radius) };
|
||||
let (sa, ha) = (p.shadow_amount / 100.0, p.highlight_amount / 100.0);
|
||||
let (st, ht) = ((p.shadow_tone / 100.0).max(0.01), (p.highlight_tone / 100.0).max(0.01));
|
||||
let color = p.color / 100.0;
|
||||
let mid = p.midtone / 100.0;
|
||||
par_rows(px, w, 1, |y, pxrow| {
|
||||
for (x, q) in pxrow.iter_mut().enumerate() {
|
||||
let i = y * w + x;
|
||||
if q[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let c = [q[0], q[1], q[2]];
|
||||
// Mask weights: 1 in deep shadows (resp. bright highlights), fading out at the tonal width.
|
||||
let ms = 1.0 - smoothstep(0.0, st, ls[i]);
|
||||
let mh = smoothstep(1.0 - ht, 1.0, lh[i]);
|
||||
let mut out = c;
|
||||
if sa > 0.0 && ms > 0.0 {
|
||||
// Lift: a gamma-like curve on the pixel, stronger for darker neighbourhoods.
|
||||
let g = 1.0 / (1.0 + 2.0 * sa * ms);
|
||||
out = out.map(|v| v.max(0.0).powf(g));
|
||||
}
|
||||
if ha > 0.0 && mh > 0.0 {
|
||||
let g = 1.0 / (1.0 + 2.0 * ha * mh);
|
||||
out = out.map(|v| 1.0 - (1.0 - v.min(1.0)).powf(g));
|
||||
}
|
||||
if mid != 0.0 {
|
||||
let k = 1.0 + mid * (1.0 - ms - mh).clamp(0.0, 1.0);
|
||||
out = out.map(|v| (v - 0.5) * k + 0.5);
|
||||
}
|
||||
let changed = (ms * sa + mh * ha).min(1.0);
|
||||
if color != 0.0 && changed > 0.0 {
|
||||
let l1 = luma(out);
|
||||
let s = 1.0 + color * changed;
|
||||
out = out.map(|v| l1 + (v - l1) * s);
|
||||
}
|
||||
q[0] = out[0].clamp(0.0, 1.0);
|
||||
q[1] = out[1].clamp(0.0, 1.0);
|
||||
q[2] = out[2].clamp(0.0, 1.0);
|
||||
}
|
||||
});
|
||||
clip_stretch(px, p.black_clip, p.white_clip);
|
||||
}
|
||||
|
||||
/// Stretches levels so `black`% of pixels clip to 0 and `white`% to 1 (luminance histogram).
|
||||
fn clip_stretch(px: &mut [[f32; 4]], black: f32, white: f32) {
|
||||
if black <= 0.0 && white <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let mut hist = [0u32; 1024];
|
||||
let mut n = 0u32;
|
||||
for q in px.iter().filter(|q| q[3] > 0.0) {
|
||||
hist[((luma([q[0], q[1], q[2]]).clamp(0.0, 1.0)) * 1023.0).round() as usize] += 1;
|
||||
n += 1;
|
||||
}
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let find = |pct: f32, rev: bool| -> f32 {
|
||||
let target = (pct.clamp(0.0, 50.0) / 100.0 * n as f32) as u32;
|
||||
let mut acc = 0;
|
||||
for k in 0..1024 {
|
||||
let i = if rev { 1023 - k } else { k };
|
||||
acc += hist[i];
|
||||
if acc > target {
|
||||
return i as f32 / 1023.0;
|
||||
}
|
||||
}
|
||||
if rev { 0.0 } else { 1.0 }
|
||||
};
|
||||
let lo = find(black, false);
|
||||
let hi = find(white, true);
|
||||
if hi - lo < 1e-3 {
|
||||
return;
|
||||
}
|
||||
for q in px.iter_mut().filter(|q| q[3] > 0.0) {
|
||||
for v in &mut q[..3] {
|
||||
*v = ((*v - lo) / (hi - lo)).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
mod shadows_highlights;
|
||||
pub use shadows_highlights::shadows_highlights;
|
||||
|
||||
/// Image › Adjustments › Replace Color: Color Range-style selection (fuzziness in 0..=200 levels)
|
||||
/// around `color`, then a hue (±180°), saturation and lightness (±100) shift weighted by it.
|
||||
|
|
@ -378,31 +280,6 @@ mod tests {
|
|||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadows_lift_dark_pixels_more_than_bright() {
|
||||
let mut px = ramp(32, 8);
|
||||
let orig = px.clone();
|
||||
shadows_highlights(&mut px, 32, 8, &ShadowsHighlights { black_clip: 0.0, white_clip: 0.0, ..Default::default() });
|
||||
let gain = |i: usize| luma([px[i][0], px[i][1], px[i][2]]) - luma([orig[i][0], orig[i][1], orig[i][2]]);
|
||||
assert!(gain(3) > 0.02, "{}", gain(3));
|
||||
assert!(gain(3) > gain(30), "{} {}", gain(3), gain(30));
|
||||
// Zero amounts and no clipping: identity.
|
||||
let mut px2 = orig.clone();
|
||||
let id = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 0.0, color: 0.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
|
||||
shadows_highlights(&mut px2, 32, 8, &id);
|
||||
assert_eq!(px2, orig);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlights_darken_bright_pixels() {
|
||||
let mut px = ramp(32, 8);
|
||||
let orig = px.clone();
|
||||
let p = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 80.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
|
||||
shadows_highlights(&mut px, 32, 8, &p);
|
||||
assert!(px[30][0] < orig[30][0]);
|
||||
assert!((px[1][0] - orig[1][0]).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_color_only_touches_matching_pixels() {
|
||||
let mut px = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,423 @@
|
|||
//! Image › Adjustments › Shadows/Highlights, as Photoshop 25.4 does it.
|
||||
//!
|
||||
//! Measured on Photoshop as a black box (synthetic 16-bit images through the adjustment, results
|
||||
//! read back exactly); no Photoshop code was read. The pieces:
|
||||
//!
|
||||
//! - **Neighbourhood brightness.** Per pixel, the mean of its brightest channel and its Rec. 601
|
||||
//! luma. Its neighbourhood value is an edge-aware average: a Gaussian of σ = 1.14 · Radius whose
|
||||
//! weights fall linearly with the difference from the pixel's own (lightly blurred) brightness,
|
||||
//! so pixels across a strong edge count less and halos stay small. It is quantised to 8 bits.
|
||||
//! - **Tonal mask.** Tone sets which neighbourhoods are treated: shadows take the complement of a
|
||||
//! Beta distribution function of the neighbourhood brightness, from Beta(1.95, 23) at Tone 0 to
|
||||
//! 1 − L at Tone 95 and over; highlights take the same of 1 − L.
|
||||
//! - **Curve.** Amount sets a curve that screens the image with itself: 1 − (1 − v)^q, blended
|
||||
//! between q = 1, 2, 4 and 8 by a strength s = 3 at 100, 2 at 50 and 1 near 15. Shadows lift each
|
||||
//! channel by mask × (curve − v); highlights darken with the mirrored curve.
|
||||
//! - **Colour.** Only the luma of that result is kept: the pixel's own colour differences are
|
||||
//! added back, scaled by the Color slider (by exp(h · ΔY / max Y) for positive values, by
|
||||
//! 1 + h · ΔY / max Y for negative ones) and shortened where they would leave the gamut.
|
||||
//! - **Midtone** is a global S-curve on the luma, k · (Y − ½) · (4Y(1 − Y))^p.
|
||||
|
||||
use crate::fxutil::gauss_blur_n;
|
||||
use crate::photo_util::par_rows;
|
||||
|
||||
use super::ShadowsHighlights;
|
||||
|
||||
const W: [f32; 3] = [0.299, 0.587, 0.114];
|
||||
|
||||
fn luma(c: [f32; 3]) -> f32 {
|
||||
W[0] * c[0] + W[1] * c[1] + W[2] * c[2]
|
||||
}
|
||||
|
||||
/// Neighbourhood σ per pixel of Radius.
|
||||
const SPATIAL: f32 = 1.1424;
|
||||
/// σ of the blur of the pixel's own brightness the edge weights compare with.
|
||||
const GUIDE: f32 = 1.3559;
|
||||
/// Brightness difference at which a neighbour's weight reaches zero.
|
||||
const RANGE: f32 = 1.0152;
|
||||
/// Brightness levels of the piecewise-linear edge-aware average.
|
||||
const LEVELS: usize = 17;
|
||||
|
||||
/// Edge-aware neighbourhood brightness of `lum` (`w × h`) for Photoshop's Radius.
|
||||
fn neighbourhood(lum: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
let mut guide = lum.to_vec();
|
||||
gauss_blur_n(&mut guide, w, h, 1, GUIDE);
|
||||
let sigma = SPATIAL * radius;
|
||||
if sigma < 0.3 {
|
||||
return guide;
|
||||
}
|
||||
// The average at each of a few brightness levels, interpolated at the pixel's own level
|
||||
// (Durand and Dorsey, "Fast bilateral filtering", 2002). The averages are smooth, so they are
|
||||
// taken on a grid `d` times coarser, with σ ≥ 4 cells there.
|
||||
let d = ((sigma / 4.0).floor() as usize).max(1);
|
||||
let (rw, rh) = (w.div_ceil(d), h.div_ceil(d));
|
||||
let cells = rw * rh;
|
||||
let step = 1.0 / (LEVELS - 1) as f32;
|
||||
let mut avg = vec![0.0f32; LEVELS * cells];
|
||||
let mut buf = vec![0.0f32; 2 * cells];
|
||||
for (k, out) in avg.chunks_exact_mut(cells).enumerate() {
|
||||
let g = k as f32 * step;
|
||||
buf.fill(0.0);
|
||||
for (i, &l) in lum.iter().enumerate() {
|
||||
let wgt = (1.0 - (l - g).abs() / RANGE).max(0.0);
|
||||
let c = 2 * ((i / w) / d * rw + (i % w) / d);
|
||||
buf[c] += wgt * l;
|
||||
buf[c + 1] += wgt;
|
||||
}
|
||||
gauss_blur_n(&mut buf, rw, rh, 2, sigma / d as f32);
|
||||
for (o, b) in out.iter_mut().zip(buf.as_chunks::<2>().0) {
|
||||
*o = if b[1] > 1e-6 { b[0] / b[1] } else { g };
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; lum.len()];
|
||||
par_rows(&mut out, w, 1, |y, row| {
|
||||
let fy = ((y as f32 + 0.5) / d as f32 - 0.5).clamp(0.0, (rh - 1) as f32);
|
||||
let (y0, ty) = (fy.floor() as usize, fy.fract());
|
||||
let y1 = (y0 + 1).min(rh - 1);
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let fx = ((x as f32 + 0.5) / d as f32 - 0.5).clamp(0.0, (rw - 1) as f32);
|
||||
let (x0, tx) = (fx.floor() as usize, fx.fract());
|
||||
let x1 = (x0 + 1).min(rw - 1);
|
||||
let at = |k: usize| {
|
||||
let a = &avg[k * cells..(k + 1) * cells];
|
||||
let top = a[y0 * rw + x0] + (a[y0 * rw + x1] - a[y0 * rw + x0]) * tx;
|
||||
let bot = a[y1 * rw + x0] + (a[y1 * rw + x1] - a[y1 * rw + x0]) * tx;
|
||||
top + (bot - top) * ty
|
||||
};
|
||||
let t = (guide[y * w + x].clamp(0.0, 1.0) / step).min((LEVELS - 1) as f32 - 1e-4);
|
||||
let k = t.floor() as usize;
|
||||
let f = t - k as f32;
|
||||
*o = at(k) * (1.0 - f) + at(k + 1) * f;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Beta(α, β) parameters of the tonal mask for Tone 0, 5, …, 95 (fitted to Photoshop's masks
|
||||
/// within 2/255).
|
||||
const MASK_BETA: [(f32, f32); 20] = [
|
||||
(1.9468, 22.9366),
|
||||
(1.7770, 18.3471),
|
||||
(1.6403, 14.7559),
|
||||
(1.5270, 11.9346),
|
||||
(1.4269, 9.6467),
|
||||
(1.3470, 7.8430),
|
||||
(1.2807, 6.4038),
|
||||
(1.2240, 5.2384),
|
||||
(1.1772, 4.2967),
|
||||
(1.1373, 3.5350),
|
||||
(1.1021, 2.9109),
|
||||
(1.0755, 2.4186),
|
||||
(1.0508, 2.0189),
|
||||
(1.0307, 1.7034),
|
||||
(1.0140, 1.4583),
|
||||
(1.0015, 1.2758),
|
||||
(1.0, 1.1403),
|
||||
(1.0, 1.0510),
|
||||
(1.0, 1.0002),
|
||||
(1.0, 1.0),
|
||||
];
|
||||
|
||||
/// The tonal mask for `tone` (0..=100) at the 256 neighbourhood levels: 1 − I_L(α, β).
|
||||
fn tonal_mask(tone: f32) -> [f32; 256] {
|
||||
let t = (tone.clamp(0.0, 95.0)) / 5.0;
|
||||
let i = (t.floor() as usize).min(MASK_BETA.len() - 2);
|
||||
let f = t - i as f32;
|
||||
let (a0, b0) = MASK_BETA[i];
|
||||
let (a1, b1) = MASK_BETA[i + 1];
|
||||
let (a, b) = ((a0 + (a1 - a0) * f) as f64, (b0 + (b1 - b0) * f) as f64);
|
||||
// Midpoint sums of the Beta density, 16 per level.
|
||||
const SUB: usize = 16;
|
||||
let n = 255 * SUB;
|
||||
let dens = |x: f64| ((a - 1.0) * x.ln() + (b - 1.0) * (1.0 - x).ln()).exp();
|
||||
let mut cum = vec![0.0f64; 256];
|
||||
let mut acc = 0.0;
|
||||
for j in 0..n {
|
||||
acc += dens((j as f64 + 0.5) / n as f64);
|
||||
if (j + 1) % SUB == 0 {
|
||||
cum[(j + 1) / SUB] = acc;
|
||||
}
|
||||
}
|
||||
let total = acc.max(f64::MIN_POSITIVE);
|
||||
std::array::from_fn(|k| (1.0 - cum[k] / total) as f32)
|
||||
}
|
||||
|
||||
/// Curve strength for Amount `a` (0..=100): 0 at 0, 1 near 15, 2 at 50, 3 at 100.
|
||||
fn strength(a: f32) -> f32 {
|
||||
let u = (a.clamp(0.0, 100.0) / 100.0).powf(0.6216);
|
||||
if u <= 0.3 { u / 0.3 } else { (1.0 + 20.0 * u) / 7.0 }
|
||||
}
|
||||
|
||||
/// The full-strength curve of strength `s`: 1 − (1 − v)^q between q = 2^⌊s⌋ and 2^(⌊s⌋+1).
|
||||
fn lift(s: f32, v: f32) -> f32 {
|
||||
let n = (s.floor() as i32).clamp(0, 2);
|
||||
let t = (s - n as f32).clamp(0.0, 1.0);
|
||||
let c = (1.0 - v).clamp(0.0, 1.0);
|
||||
let lo = c.powi(1 << n);
|
||||
let hi = lo * lo;
|
||||
1.0 - ((1.0 - t) * lo + t * hi)
|
||||
}
|
||||
|
||||
/// Linear interpolation in a table of `(x, y)` sorted by `x`.
|
||||
fn interp(table: &[(f32, f32)], x: f32) -> f32 {
|
||||
let Some(&(x0, y0)) = table.first() else { return 0.0 };
|
||||
if x <= x0 {
|
||||
return y0;
|
||||
}
|
||||
for p in table.windows(2) {
|
||||
let ((xa, ya), (xb, yb)) = (p[0], p[1]);
|
||||
if x <= xb {
|
||||
return ya + (yb - ya) * (x - xa) / (xb - xa);
|
||||
}
|
||||
}
|
||||
table.last().map_or(0.0, |p| p.1)
|
||||
}
|
||||
|
||||
/// Color: the h of the colour-difference gain for positive values (exp(h · F))…
|
||||
const COLOR_UP: [(f32, f32); 12] = [
|
||||
(0.0, 0.0),
|
||||
(1.0, 0.1566),
|
||||
(2.0, 0.2271),
|
||||
(5.0, 0.3776),
|
||||
(10.0, 0.5653),
|
||||
(20.0, 0.8669),
|
||||
(30.0, 1.1299),
|
||||
(40.0, 1.3748),
|
||||
(50.0, 1.6093),
|
||||
(60.0, 1.8375),
|
||||
(75.0, 2.1721),
|
||||
(100.0, 2.7193),
|
||||
];
|
||||
/// … and for negative values (1 + h · F), by −Color.
|
||||
const COLOR_DOWN: [(f32, f32); 9] =
|
||||
[(0.0, 0.0), (1.0, -0.0145), (5.0, -0.0709), (10.0, -0.1373), (20.0, -0.2570), (30.0, -0.3612), (50.0, -0.5312), (75.0, -0.6866), (100.0, -0.7968)];
|
||||
|
||||
/// Midtone: (value, k, p) of k · (Y − ½) · (4Y(1 − Y))^p.
|
||||
const MIDTONE: [(f32, f32, f32); 11] = [
|
||||
(-100.0, -0.6955, 2.267),
|
||||
(-75.0, -0.5873, 2.362),
|
||||
(-50.0, -0.4429, 2.492),
|
||||
(-25.0, -0.2514, 2.667),
|
||||
(-10.0, -0.1088, 2.803),
|
||||
(0.0, 0.0, 2.9),
|
||||
(10.0, 0.1206, 3.016),
|
||||
(25.0, 0.3262, 3.216),
|
||||
(50.0, 0.7423, 3.625),
|
||||
(75.0, 1.2635, 4.160),
|
||||
(100.0, 1.9032, 4.850),
|
||||
];
|
||||
|
||||
fn midtone(m: f32, y: f32) -> f32 {
|
||||
if m == 0.0 {
|
||||
return y;
|
||||
}
|
||||
let ks: Vec<(f32, f32)> = MIDTONE.iter().map(|r| (r.0, r.1)).collect();
|
||||
let ps: Vec<(f32, f32)> = MIDTONE.iter().map(|r| (r.0, r.2)).collect();
|
||||
let (k, p) = (interp(&ks, m), interp(&ps, m));
|
||||
(y + k * (y - 0.5) * (4.0 * y * (1.0 - y)).max(0.0).powf(p)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn level(v: f32) -> usize {
|
||||
(v.clamp(0.0, 1.0) * 255.0).round() as usize
|
||||
}
|
||||
|
||||
/// Image › Adjustments › Shadows/Highlights on straight RGBA `w × h`.
|
||||
pub fn shadows_highlights(px: &mut [[f32; 4]], w: usize, h: usize, p: &ShadowsHighlights) {
|
||||
if px.is_empty() || px.len() != w * h {
|
||||
return;
|
||||
}
|
||||
let (sa, ha) = (p.shadow_amount, p.highlight_amount);
|
||||
let lum: Vec<f32> = px.iter().map(|q| (q[0].max(q[1]).max(q[2]) + luma([q[0], q[1], q[2]])) / 2.0).collect();
|
||||
let ls = (sa > 0.0).then(|| neighbourhood(&lum, w, h, p.shadow_radius));
|
||||
let lh = (ha > 0.0).then(|| match &ls {
|
||||
Some(l) if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 => l.clone(),
|
||||
_ => neighbourhood(&lum, w, h, p.highlight_radius),
|
||||
});
|
||||
let (ms, mh) = (tonal_mask(p.shadow_tone), tonal_mask(p.highlight_tone));
|
||||
let (ss, hs) = (strength(sa), strength(ha));
|
||||
// The new luma of every pixel.
|
||||
let mut y2 = vec![0.0f32; px.len()];
|
||||
par_rows(&mut y2, w, 1, |y, row| {
|
||||
for (x, out) in row.iter_mut().enumerate() {
|
||||
let i = y * w + x;
|
||||
let q = px[i];
|
||||
let mut adj = [q[0], q[1], q[2]];
|
||||
if let Some(l) = &ls {
|
||||
let m = ms[level(l[i])];
|
||||
for v in &mut adj {
|
||||
*v += m * (lift(ss, *v) - *v);
|
||||
}
|
||||
}
|
||||
if let Some(l) = &lh {
|
||||
let m = mh[255 - level(l[i])];
|
||||
for (v, &o) in adj.iter_mut().zip(&q[..3]) {
|
||||
*v -= m * (lift(hs, 1.0 - o) - (1.0 - o));
|
||||
}
|
||||
}
|
||||
*out = midtone(p.midtone, luma(adj).clamp(0.0, 1.0));
|
||||
}
|
||||
});
|
||||
clip_levels(&mut y2, px, p.black_clip, p.white_clip);
|
||||
let gain = |f: f32| {
|
||||
if p.color >= 0.0 { (interp(&COLOR_UP, p.color) * f).exp() } else { 1.0 + interp(&COLOR_DOWN, -p.color) * f }
|
||||
};
|
||||
par_rows(px, w, 1, |y, row| {
|
||||
for (x, q) in row.iter_mut().enumerate() {
|
||||
if q[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let y1 = luma([q[0], q[1], q[2]]);
|
||||
let yn = y2[y * w + x];
|
||||
let f = (yn - y1).abs() / yn.max(y1).max(1e-6);
|
||||
let g = gain(f);
|
||||
let d = [(q[0] - y1) * g, (q[1] - y1) * g, (q[2] - y1) * g];
|
||||
// Shorten the colour difference where it would leave 0..=1.
|
||||
let mut k = 1.0f32;
|
||||
for v in d {
|
||||
if v > 1e-9 {
|
||||
k = k.min((1.0 - yn) / v);
|
||||
} else if v < -1e-9 {
|
||||
k = k.min(yn / -v);
|
||||
}
|
||||
}
|
||||
let k = k.clamp(0.0, 1.0);
|
||||
for c in 0..3 {
|
||||
q[c] = (yn + k * d[c]).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Black and White Clip: stretches the new luma so `black`% of pixels reach 0 and `white`% reach 1.
|
||||
fn clip_levels(y2: &mut [f32], px: &[[f32; 4]], black: f32, white: f32) {
|
||||
if black <= 0.0 && white <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let mut hist = vec![0u32; 4096];
|
||||
let mut n = 0u32;
|
||||
for (&y, q) in y2.iter().zip(px) {
|
||||
if q[3] > 0.0 {
|
||||
hist[(y.clamp(0.0, 1.0) * 4095.0).round() as usize] += 1;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let find = |pct: f32, rev: bool| -> f32 {
|
||||
let target = (pct.clamp(0.0, 50.0) / 100.0 * n as f32) as u32;
|
||||
let mut acc = 0;
|
||||
for k in 0..4096 {
|
||||
let i = if rev { 4095 - k } else { k };
|
||||
acc += hist[i];
|
||||
if acc > target {
|
||||
return i as f32 / 4095.0;
|
||||
}
|
||||
}
|
||||
if rev { 0.0 } else { 1.0 }
|
||||
};
|
||||
let (lo, hi) = (if black > 0.0 { find(black, false) } else { 0.0 }, if white > 0.0 { find(white, true) } else { 1.0 });
|
||||
if hi - lo < 1e-3 {
|
||||
return;
|
||||
}
|
||||
for y in y2.iter_mut() {
|
||||
*y = ((*y - lo) / (hi - lo)).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A flat `w × h` grey or colour image with one different pixel in the middle; returns that
|
||||
/// pixel after Shadows/Highlights.
|
||||
fn probe(bg: [f32; 3], px: [f32; 3], p: &ShadowsHighlights) -> [f32; 3] {
|
||||
let (w, h) = (121, 121);
|
||||
let mut img = vec![[bg[0], bg[1], bg[2], 1.0]; w * h];
|
||||
img[60 * w + 60] = [px[0], px[1], px[2], 1.0];
|
||||
shadows_highlights(&mut img, w, h, p);
|
||||
let q = img[60 * w + 60];
|
||||
[q[0], q[1], q[2]]
|
||||
}
|
||||
|
||||
fn levels(v: [i32; 3]) -> [f32; 3] {
|
||||
v.map(|c| c as f32 / 32768.0)
|
||||
}
|
||||
|
||||
fn off(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
a.iter().zip(b).map(|(x, y)| (x - y).abs() * 255.0).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// Photoshop 25.4's results (16-bit levels of 32768) for grey pixels lifted by the default
|
||||
/// Shadows (35 %, Tone 50 %, Radius 30 px; Color 0), in black surroundings and in grey 0.5.
|
||||
#[test]
|
||||
fn grey_matches_photoshop() {
|
||||
let p = ShadowsHighlights { color: 0.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
|
||||
for (bg, want) in [(0.0, [11389, 19422, 28451, 31931]), (0.5, [5211, 9909, 18229, 25700])] {
|
||||
for (v, w) in [0.125f32, 0.25, 0.5, 0.749969].into_iter().zip(want) {
|
||||
let got = probe([bg; 3], [v; 3], &p);
|
||||
assert!(off(got, levels([w; 3])) < 2.0, "bg {bg} v {v}: {} vs {}", got[0] * 32768.0, w);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour pixels keep their colour differences, scaled by Color (+20 here; Amount 35 %,
|
||||
/// Radius 10 px), in black, 0.15 and 0.3 grey surroundings: Photoshop's results.
|
||||
#[test]
|
||||
fn colour_matches_photoshop() {
|
||||
let p = ShadowsHighlights { shadow_radius: 10.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
|
||||
for (bg, px, want) in [
|
||||
(0.0, [3277, 1638, 0], [8235, 5202, 2170]),
|
||||
(0.0, [8192, 6554, 4915], [19350, 16514, 13676]),
|
||||
(4915.0 / 32768.0, [4915, 3277, 1638], [10124, 7424, 4723]),
|
||||
(9830.0 / 32768.0, [11469, 9830, 8192], [16540, 14376, 12214]),
|
||||
] {
|
||||
let got = probe([bg; 3], levels(px), &p);
|
||||
assert!(off(got, levels(want)) < 2.0, "{px:?} on {bg}: {:?} vs {want:?}", got.map(|c| (c * 32768.0).round()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Across a strong edge the neighbourhood barely leaks (Amount 100 %, Tone 100 %, Radius
|
||||
/// 10 px; 0.1 | 0.9): Photoshop's results 8, 2 and 1 pixels either side.
|
||||
#[test]
|
||||
fn edge_matches_photoshop() {
|
||||
let (w, h) = (200, 24);
|
||||
let (a, b) = (3277.0 / 32768.0, 29491.0 / 32768.0);
|
||||
let mut img: Vec<[f32; 4]> = (0..w * h).map(|i| if i % w < 100 { [a, a, a, 1.0] } else { [b, b, b, 1.0] }).collect();
|
||||
let p = ShadowsHighlights {
|
||||
shadow_amount: 100.0,
|
||||
shadow_tone: 100.0,
|
||||
shadow_radius: 10.0,
|
||||
color: 0.0,
|
||||
black_clip: 0.0,
|
||||
white_clip: 0.0,
|
||||
..Default::default()
|
||||
};
|
||||
shadows_highlights(&mut img, w, h, &p);
|
||||
let row = &img[12 * w..13 * w];
|
||||
for (x, want) in [(92, 16370), (98, 14439), (99, 12146), (100, 30891), (101, 30403), (102, 30146), (108, 29979)] {
|
||||
let got = row[x][0] * 32768.0;
|
||||
assert!((got - want as f32).abs() / 32768.0 * 255.0 < 6.0, "x {x}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strength_and_curve_follow_photoshop() {
|
||||
assert!((strength(50.0) - 2.0).abs() < 2e-3 && (strength(100.0) - 3.0).abs() < 1e-4);
|
||||
assert!((strength(15.0) - 1.02).abs() < 0.01 && (strength(1.0) - 0.19).abs() < 0.01);
|
||||
// Amount 50 is the image screened with itself twice: 1 − (1 − v)^4.
|
||||
for v in [0.1f32, 0.3, 0.6] {
|
||||
assert!((lift(2.0, v) - (1.0 - (1.0 - v).powi(4))).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tonal_mask_spans_photoshop_tones() {
|
||||
let m95 = tonal_mask(95.0);
|
||||
assert!((m95[128] - (1.0 - 128.0 / 255.0)).abs() < 1e-3, "{}", m95[128]);
|
||||
assert_eq!(tonal_mask(100.0), m95);
|
||||
// Tone 0 still reaches about 7 % into the levels (Photoshop: half at level 18).
|
||||
let m0 = tonal_mask(0.0);
|
||||
assert!(m0[0] > 0.999 && (m0[18] - 0.5).abs() < 0.03 && m0[60] < 0.02, "{} {}", m0[18], m0[60]);
|
||||
}
|
||||
}
|
||||
|
|
@ -368,7 +368,8 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
|
|||
any = true;
|
||||
let o = ((y - t.y0) as usize * w + (x - t.x0) as usize) * n;
|
||||
for c in 0..a {
|
||||
outp[o + c] = (acc[c] / al).clamp(0.0, 1.0) as f32;
|
||||
// Integer storage clips on write; float colours retain their signed/HDR range.
|
||||
outp[o + c] = (acc[c] / al) as f32;
|
||||
}
|
||||
outp[o + a] = al as f32;
|
||||
}
|
||||
|
|
@ -482,6 +483,44 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translation_preserves_colour_range_at_every_depth() {
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
|
||||
let h = Homography([1.0, 0.0, 7.0, 0.0, 1.0, -3.0, 0.0, 0.0, 1.0]);
|
||||
for sample in SampleType::ALL {
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
|
||||
for alpha in [false, true] {
|
||||
let mut s = Surface::new(PixelFormat::new(mode, sample, alpha));
|
||||
let mut pixel = if mode == ColorMode::Rgb { vec![2.0, -0.25, 0.5] } else { vec![2.0] };
|
||||
if alpha {
|
||||
pixel.push(0.5);
|
||||
}
|
||||
s.fill_rect(Rect::new(4, 4, 8, 8), &pixel);
|
||||
let mut expected = s.pixel(5, 5);
|
||||
if !alpha {
|
||||
expected.push(1.0);
|
||||
}
|
||||
for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
|
||||
let out = warp_surface(&s, s.content_bounds(), &h, interp).unwrap();
|
||||
assert_eq!(out.pixel(12, 2), expected, "{sample:?}/{mode:?}/alpha={alpha}/{interp:?}");
|
||||
assert_eq!(out.pixel(0, 0).last(), Some(&0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bilinear_translation_interpolates_signed_hdr_colours() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA32F);
|
||||
s.write_pixel(0, 0, &[2.0, -0.5, 0.25, 0.5]);
|
||||
s.write_pixel(1, 0, &[4.0, -0.25, 0.75, 0.5]);
|
||||
let h = Homography([1.0, 0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
|
||||
let out = warp_surface(&s, s.content_bounds(), &h, Interp::Bilinear).unwrap();
|
||||
assert_eq!(out.pixel(1, 0), vec![3.0, -0.375, 0.5, 0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_doubles_size_and_rotation_keeps_area() {
|
||||
let mut s = rgba();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
//! interpolated barycentrically, then the source is sampled there (premultiplied, nearest /
|
||||
//! bilinear / Catmull-Rom bicubic, like Free Transform). Cells are a few source pixels across, so
|
||||
//! the piecewise-affine inverse is within a small fraction of a pixel of the true one; affine
|
||||
//! maps (the identity included) are reproduced exactly. Where the warp folds over itself the
|
||||
//! maps (the identity included) are reproduced exactly. Where the warp shrinks the source, each
|
||||
//! pixel instead averages a grid of bilinear subsamples over its source footprint (about one per
|
||||
//! source pixel), so thin lines don't alias. Where the warp folds over itself the
|
||||
//! later (lower-right) cell wins. Work is split per destination tile (parallel on native).
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
|
|
@ -25,6 +27,9 @@ fn catmull_rom(t: f64) -> [f64; 4] {
|
|||
const CELL: f64 = 4.0;
|
||||
/// Most cells along one axis (bounds memory and time on huge layers).
|
||||
const MAX_CELLS: usize = 512;
|
||||
/// Most subsamples per axis when a warp shrinks the source; also the longest source step per
|
||||
/// destination pixel the filter follows (bounds the footprint each tile reads).
|
||||
const MAX_SS: f64 = 8.0;
|
||||
|
||||
/// Places a smart object's source image (`src`, its pixels in `src_rect`) in the document:
|
||||
/// through `warp` (source space) and then the affine `t` (source → document) in one resampling
|
||||
|
|
@ -172,14 +177,37 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
|||
}
|
||||
let w = t.width() as usize;
|
||||
let h = t.height() as usize;
|
||||
// Source position per destination pixel (NaN = not covered).
|
||||
// Subsamples along one destination axis, from the source step `j` it makes: one where
|
||||
// the warp keeps or enlarges the source, else about one per source pixel the output pixel
|
||||
// spans (an area average, as the transform path gets from its pre-reduction; a single
|
||||
// bicubic sample aliases thin lines when shrinking). The subsamples are bilinear: the
|
||||
// box does the filtering, and it is ~4x cheaper than bicubic at no loss.
|
||||
let count = |j: [f64; 2]| if interp == Interp::Nearest { 1 } else { j[0].hypot(j[1]).round().clamp(1.0, MAX_SS) as usize };
|
||||
// Source position per destination pixel (NaN = not covered) and the triangle it came
|
||||
// from. Per triangle: its source step per destination pixel along x and y (`[dx_u, dx_v,
|
||||
// dy_u, dy_v]`, the inverse map's Jacobian, constant over a triangle; zero when
|
||||
// point-sampled) and the subsamples along each.
|
||||
let mut uv = vec![[f64::NAN; 2]; w * h];
|
||||
for tri in tris {
|
||||
let mut owner = vec![0u32; w * h];
|
||||
let mut jac: Vec<([f64; 4], usize, usize)> = Vec::with_capacity(tris.len());
|
||||
for (k, tri) in tris.iter().enumerate() {
|
||||
let [(p0, s0), (p1, s1), (p2, s2)] = tri.map(|v| verts[v]);
|
||||
let det = (p1[0] - p0[0]) * (p2[1] - p0[1]) - (p2[0] - p0[0]) * (p1[1] - p0[1]);
|
||||
if det.abs() < 1e-12 {
|
||||
jac.push(([0.0; 4], 1, 1));
|
||||
continue;
|
||||
}
|
||||
let (d1, d2) = ([s1[0] - s0[0], s1[1] - s0[1]], [s2[0] - s0[0], s2[1] - s0[1]]);
|
||||
let (l1x, l1y, l2x, l2y) = ((p2[1] - p0[1]) / det, (p0[0] - p2[0]) / det, (p0[1] - p1[1]) / det, (p1[0] - p0[0]) / det);
|
||||
// Capped, so a sliver triangle can't ask for an unbounded footprint.
|
||||
let cap = |j: [f64; 2]| {
|
||||
let l = j[0].hypot(j[1]);
|
||||
if l > MAX_SS { [j[0] * MAX_SS / l, j[1] * MAX_SS / l] } else { j }
|
||||
};
|
||||
let jx = cap([l1x * d1[0] + l2x * d2[0], l1x * d1[1] + l2x * d2[1]]);
|
||||
let jy = cap([l1y * d1[0] + l2y * d2[0], l1y * d1[1] + l2y * d2[1]]);
|
||||
let (nx, ny) = (count(jx), count(jy));
|
||||
jac.push(if nx * ny == 1 { ([0.0; 4], 1, 1) } else { ([jx[0], jx[1], jy[0], jy[1]], nx, ny) });
|
||||
let bx0 = (p0[0].min(p1[0]).min(p2[0]) - 0.5).floor().max(f64::from(t.x0)) as i32;
|
||||
let by0 = (p0[1].min(p1[1]).min(p2[1]) - 0.5).floor().max(f64::from(t.y0)) as i32;
|
||||
let bx1 = (p0[0].max(p1[0]).max(p2[0]) + 0.5).ceil().min(f64::from(t.x1)) as i32;
|
||||
|
|
@ -197,16 +225,18 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
|||
}
|
||||
let o = (y - t.y0) as usize * w + (x - t.x0) as usize;
|
||||
uv[o] = [l0 * s0[0] + l1 * s1[0] + l2 * s2[0], l0 * s0[1] + l1 * s1[1] + l2 * s2[1]];
|
||||
owner[o] = k as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Source footprint of the covered pixels.
|
||||
// Source footprint of the covered pixels, including the spread of their subsamples.
|
||||
let (mut fx0, mut fy0, mut fx1, mut fy1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||
for q in uv.iter().filter(|q| !q[0].is_nan()) {
|
||||
fx0 = fx0.min(q[0]);
|
||||
fy0 = fy0.min(q[1]);
|
||||
fx1 = fx1.max(q[0]);
|
||||
fy1 = fy1.max(q[1]);
|
||||
for (q, j) in uv.iter().zip(&owner).filter(|(q, _)| !q[0].is_nan()).map(|(q, &k)| (q, jac[k as usize].0)) {
|
||||
let (ex, ey) = (0.5 * (j[0].abs() + j[2].abs()), 0.5 * (j[1].abs() + j[3].abs()));
|
||||
fx0 = fx0.min(q[0] - ex);
|
||||
fy0 = fy0.min(q[1] - ey);
|
||||
fx1 = fx1.max(q[0] + ex);
|
||||
fy1 = fy1.max(q[1] + ey);
|
||||
}
|
||||
if fx0 > fx1 {
|
||||
return None;
|
||||
|
|
@ -233,11 +263,9 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
|||
let mut outp = vec![0.0f32; w * h * n];
|
||||
let mut any = false;
|
||||
let mut acc = [0.0f64; 8];
|
||||
for (o, q) in uv.iter().enumerate() {
|
||||
if q[0].is_nan() {
|
||||
continue;
|
||||
}
|
||||
let (u, v) = (q[0] - 0.5, q[1] - 0.5);
|
||||
let mut one = [0.0f64; 8];
|
||||
// One premultiplied sample at source position (u, v) (pixel centres at integers).
|
||||
let point = |interp: Interp, u: f64, v: f64, acc: &mut [f64; 8]| {
|
||||
acc[..n].fill(0.0);
|
||||
match interp {
|
||||
Interp::Nearest => {
|
||||
|
|
@ -276,6 +304,31 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
|||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for (o, (q, &k)) in uv.iter().zip(&owner).enumerate() {
|
||||
if q[0].is_nan() {
|
||||
continue;
|
||||
}
|
||||
let (j, nx, ny) = jac[k as usize];
|
||||
if nx * ny == 1 {
|
||||
point(interp, q[0] - 0.5, q[1] - 0.5, &mut acc);
|
||||
} else {
|
||||
acc[..n].fill(0.0);
|
||||
for sy in 0..ny {
|
||||
let t = (sy as f64 + 0.5) / ny as f64 - 0.5;
|
||||
for sx in 0..nx {
|
||||
let s = (sx as f64 + 0.5) / nx as f64 - 0.5;
|
||||
point(Interp::Bilinear, q[0] + s * j[0] + t * j[2] - 0.5, q[1] + s * j[1] + t * j[3] - 0.5, &mut one);
|
||||
for (c, v) in acc.iter_mut().zip(&one).take(n) {
|
||||
*c += v;
|
||||
}
|
||||
}
|
||||
}
|
||||
let k = 1.0 / (nx * ny) as f64;
|
||||
for c in acc.iter_mut().take(n) {
|
||||
*c *= k;
|
||||
}
|
||||
}
|
||||
let al = acc[a].clamp(0.0, 1.0);
|
||||
// Float noise (a vertex a hair off the pixel grid) must not leave invisible
|
||||
// colour behind in transparent pixels.
|
||||
|
|
@ -407,6 +460,32 @@ mod tests {
|
|||
assert!(c.y0 < r.y0 - 2 && c.x0 < r.x0 && c.x1 > r.x1, "the top rises and spreads past the box: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shrinking_mesh_warp_is_prefiltered() {
|
||||
// A warped smart object placed at ~1/5 size: a grid of 1 px lines every 4 px must come
|
||||
// out its area average (9/16 white) everywhere, not point-sampled moiré (crinkled,
|
||||
// doubled or missing lines), like the affine path's `large_downscale_is_prefiltered`.
|
||||
for s in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let (w, h) = (400, 320);
|
||||
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, s, true));
|
||||
let px: Vec<f32> =
|
||||
(0..h).flat_map(|y| (0..w).map(move |x| if x % 4 == 0 || y % 4 == 0 { 0.0 } else { 1.0 })).flat_map(|v| [v, v, v, 1.0]).collect();
|
||||
let r = Rect::new(0, 0, w, h);
|
||||
src.write_region(r, &px);
|
||||
let warp = Warp::preset(WarpStyle::Arc, 15.0, [0.0, 0.0, f64::from(w), f64::from(h)]);
|
||||
let t = photocraft_geom::Affine { m: [0.2, 0.0, 0.0, 0.2, 7.0, 5.0] };
|
||||
let out = place_source(&src, r, &t, Some(&warp)).unwrap();
|
||||
let b = out.content_bounds();
|
||||
let o = out.read_region(b);
|
||||
let opaque: Vec<f32> = o.as_chunks::<4>().0.iter().filter(|p| p[3] >= 0.999).map(|p| p[0]).collect();
|
||||
assert!(opaque.len() > 2000, "{s:?}: {} opaque pixels", opaque.len());
|
||||
let worst = opaque.iter().map(|v| (v - 0.5625).abs()).fold(0.0f32, f32::max);
|
||||
// An area average over the ~5 px footprint still covers one or two lines per axis
|
||||
// (up to 0.2 off); point sampling lands wholly on or off a line (up to 0.56 off).
|
||||
assert!(worst < 0.25, "{s:?}: worst deviation from the area average {worst}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_masks_keep_their_default() {
|
||||
let mut m = Surface::with_default(PixelFormat::GRAY8, &[1.0]);
|
||||
|
|
|
|||
|
|
@ -96,13 +96,15 @@ impl Headless {
|
|||
st.source_read_only = o.source_read_only;
|
||||
}
|
||||
let d = &self.session.documents()[index];
|
||||
let mut warnings = o.warnings;
|
||||
warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&d.doc));
|
||||
Ok(json!({
|
||||
"index": index,
|
||||
"name": d.doc.name,
|
||||
"width": d.doc.size.width,
|
||||
"height": d.doc.size.height,
|
||||
"layers": d.doc.layer_count(),
|
||||
"warnings": o.warnings,
|
||||
"warnings": warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -240,7 +242,7 @@ impl Headless {
|
|||
}
|
||||
};
|
||||
let target_text = target.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
|
||||
let warnings = match &self.filesystem {
|
||||
let mut warnings = match &self.filesystem {
|
||||
Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
|
||||
Filesystem::TrustedLocal => {
|
||||
let writer = self.writers.entry(doc.id.0).or_default();
|
||||
|
|
@ -252,6 +254,7 @@ impl Headless {
|
|||
warnings
|
||||
}
|
||||
};
|
||||
warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&doc));
|
||||
// A layered document save (PSD, PSB, OpenRaster or .pcraft) is a full write, not a flattened copy:
|
||||
// record the current revision as saved so the session stops reporting `dirty`, and make
|
||||
// the file the document's path. A flat export (PNG, JPEG, …) is a copy and leaves both
|
||||
|
|
@ -317,21 +320,7 @@ impl Headless {
|
|||
}
|
||||
|
||||
pub fn command_list(&self) -> Value {
|
||||
Value::Array(
|
||||
command_specs()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"id": c.id,
|
||||
"label": c.label,
|
||||
"menu": c.menu,
|
||||
"shortcut": c.shortcut,
|
||||
"params": c.params,
|
||||
"enabled": self.session.is_enabled(c.id),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
Value::Array(command_specs().iter().map(|c| photocraft_engine::commands::list_entry(&self.session, c, self.session.disabled_reason(c.id))).collect())
|
||||
}
|
||||
|
||||
pub fn command_run(&mut self, id: &str, params: Value) -> Result<Value, AutomationError> {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ use serde_json::{Value, json};
|
|||
use crate::budgets::{BatchReplyBudget, write_reply};
|
||||
use crate::security::{
|
||||
ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line,
|
||||
read_bounded_line,
|
||||
read_bounded_line, repeat_auth_reply,
|
||||
};
|
||||
use crate::{AutomationError, Headless};
|
||||
|
||||
|
|
@ -288,7 +288,7 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
|
|||
LineRead::Line => {}
|
||||
}
|
||||
let reply = if authenticated {
|
||||
respond(h, &line)
|
||||
repeat_auth_reply(&line).unwrap_or_else(|| respond(h, &line))
|
||||
} else {
|
||||
let (reply, ok) = authentication_reply(&line, token);
|
||||
authenticated = ok;
|
||||
|
|
@ -780,6 +780,36 @@ mod tests {
|
|||
assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() {
|
||||
use std::io::{BufReader, Write as _};
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let h = Arc::new(Mutex::new(Headless::new()));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = serve_tcp("127.0.0.1:0", h, TOKEN.into(), move |a| tx.send(a).unwrap());
|
||||
});
|
||||
let addr = rx.recv().unwrap();
|
||||
let mut s = std::net::TcpStream::connect(addr).unwrap();
|
||||
let mut reader = BufReader::new(s.try_clone().unwrap());
|
||||
let mut line = String::new();
|
||||
for id in ["auth", "again"] {
|
||||
writeln!(s, r#"{{"id":"{id}","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
}
|
||||
let again: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(again["id"], "again");
|
||||
assert_eq!(again["ok"], false);
|
||||
assert_eq!(again["error"], "already authenticated");
|
||||
writeln!(s, r#"{{"id":"m","method":"methods"}}"#).unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(v["id"], "m");
|
||||
assert_eq!(v["ok"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_rejects_requests_before_authentication() {
|
||||
use std::io::{BufReader, Write as _};
|
||||
|
|
|
|||
|
|
@ -118,6 +118,18 @@ pub fn authentication_reply(line: &str, expected_token: &str) -> (Value, bool) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Answer a repeat `auth` frame on an already authenticated connection. Returns `None` for any
|
||||
/// other frame, which the caller dispatches as usual.
|
||||
/// Without this, a second `auth` fell through to the method dispatcher as an unknown method.
|
||||
pub fn repeat_auth_reply(line: &str) -> Option<Value> {
|
||||
let req: Value = serde_json::from_str(line).ok()?;
|
||||
if req.get("method").and_then(Value::as_str) != Some(AUTH_METHOD) {
|
||||
return None;
|
||||
}
|
||||
let id = req.get("id").cloned().unwrap_or(Value::Null);
|
||||
Some(json!({"id": id, "ok": false, "error": "already authenticated"}))
|
||||
}
|
||||
|
||||
fn read_token_file(path: &Path) -> Result<String, AutomationError> {
|
||||
let token = std::fs::read_to_string(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
let token = token.trim().to_owned();
|
||||
|
|
@ -294,6 +306,17 @@ mod tests {
|
|||
assert_eq!(reply["result"]["authenticated"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_auth_on_an_authenticated_connection_says_so() {
|
||||
let line = json!({"id": 3, "method": AUTH_METHOD, "params": {"token": "anything"}}).to_string();
|
||||
let reply = repeat_auth_reply(&line).unwrap();
|
||||
assert_eq!(reply["id"], 3);
|
||||
assert_eq!(reply["ok"], false);
|
||||
assert_eq!(reply["error"], "already authenticated");
|
||||
assert!(repeat_auth_reply(r#"{"id":4,"method":"methods"}"#).is_none());
|
||||
assert!(repeat_auth_reply("{not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_file_round_trips_between_server_and_client() {
|
||||
let path = std::env::temp_dir().join(format!("photocraft-control-token-{}-{}.txt", std::process::id(), generate_token().unwrap()));
|
||||
|
|
|
|||
|
|
@ -792,7 +792,9 @@ impl PhotocraftMcp {
|
|||
}
|
||||
}
|
||||
|
||||
#[tool(description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now.")]
|
||||
#[tool(description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now. \
|
||||
`disabledReason` says why a command is off (null when enabled); `needsDocument` is true when it is off because no \
|
||||
document is open: create or open one first (doc_new, doc_open).")]
|
||||
async fn command_list(&self, Parameters(p): Parameters<ListParams>) -> Result<CallToolResult, McpError> {
|
||||
let all = if let Some(r) = self.headless_op(|h| Ok(h.command_list())).await {
|
||||
r
|
||||
|
|
|
|||
|
|
@ -170,6 +170,12 @@ async fn command_list_filters() {
|
|||
let enabled = json_of(&call(&client, "command_list", json!({"enabled_only": true})).await);
|
||||
assert!(enabled.as_array().unwrap().iter().any(|c| c["id"] == "file.new"));
|
||||
assert!(!enabled.as_array().unwrap().iter().any(|c| c["id"] == "layer.new.layer"), "needs a document");
|
||||
// The full list says why a document command is off with no document open (#3664).
|
||||
let layer = all.as_array().unwrap().iter().find(|c| c["id"] == "layer.new.layer").unwrap();
|
||||
assert_eq!((layer["enabled"].clone(), layer["needsDocument"].clone()), (json!(false), json!(true)), "{layer}");
|
||||
assert!(layer["disabledReason"].as_str().is_some_and(|r| r.starts_with("no document")), "{layer}");
|
||||
let new = all.as_array().unwrap().iter().find(|c| c["id"] == "file.new").unwrap();
|
||||
assert_eq!((new["needsDocument"].clone(), new["disabledReason"].clone()), (json!(false), Value::Null), "{new}");
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
//! #3391: `doc.save`, `doc.open`, and type commands emit warnings when text uses a missing font.
|
||||
|
||||
use photocraft_automation::Headless;
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp_dir() -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pc-font-warn-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_font_warnings_emitted_in_headless_and_type_commands() {
|
||||
let dir = tmp_dir();
|
||||
let psd_path = dir.join("card.psd");
|
||||
let png_path = dir.join("card.png");
|
||||
|
||||
let mut h = Headless::trusted_local();
|
||||
h.handle("doc.new", json!({"width": 600, "height": 300, "background": "white"})).unwrap();
|
||||
|
||||
// 1. type.create with missing font
|
||||
let created = h
|
||||
.handle("engine.execute", json!({"command": "type.create", "params": {"x": 30, "y": 100, "text": "Headline", "font": "NoSuchFamily123", "size": 40}}))
|
||||
.unwrap();
|
||||
assert_eq!(created["warnings"], json!(["font 'NoSuchFamily123' is not installed; text may render using a fallback face"]));
|
||||
|
||||
// 2. doc.save card.psd
|
||||
let saved_psd = h.handle("doc.save", json!({"path": psd_path.to_str().unwrap()})).unwrap();
|
||||
let warnings = saved_psd["warnings"].as_array().expect("warnings array in doc.save");
|
||||
assert!(
|
||||
warnings.iter().any(|w| w == "font 'NoSuchFamily123' is not installed; text may render using a fallback face"),
|
||||
"doc.save PSD warning: {saved_psd}"
|
||||
);
|
||||
|
||||
// 3. doc.save card.png
|
||||
let saved_png = h.handle("doc.save", json!({"path": png_path.to_str().unwrap()})).unwrap();
|
||||
let warnings = saved_png["warnings"].as_array().expect("warnings array in doc.save");
|
||||
assert!(
|
||||
warnings.iter().any(|w| w == "font 'NoSuchFamily123' is not installed; text may render using a fallback face"),
|
||||
"doc.save PNG warning: {saved_png}"
|
||||
);
|
||||
|
||||
// 4. doc.close then doc.open
|
||||
h.handle("doc.close", json!({})).unwrap();
|
||||
let opened = h.handle("doc.open", json!({"path": psd_path.to_str().unwrap()})).unwrap();
|
||||
let warnings = opened["warnings"].as_array().expect("warnings array in doc.open");
|
||||
assert!(
|
||||
warnings.iter().any(|w| w.as_str().unwrap_or("").contains("is not installed; text may render using a fallback face")),
|
||||
"doc.open warning: {opened}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
|
@ -296,6 +296,13 @@ impl Profile {
|
|||
self.a2b.iter().all(Option::is_none) && (self.matrix.is_some() && self.trc.is_some() || self.gray_trc.is_some())
|
||||
}
|
||||
|
||||
/// Whether colours can be converted into this profile for every intent. Input profiles with
|
||||
/// only AToB tables (Apple's Wide Color Sharing Profile on iPhone photos) can't: they
|
||||
/// describe a source but have no PCS → device path.
|
||||
pub fn can_be_destination(&self) -> bool {
|
||||
[Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation].into_iter().all(|i| self.pcs_to_device(i).is_ok())
|
||||
}
|
||||
|
||||
fn lut_for(luts: &[Option<Lut>; 3], intent: Intent) -> Option<&Lut> {
|
||||
let i = match intent {
|
||||
Intent::Perceptual => 0,
|
||||
|
|
|
|||
|
|
@ -450,6 +450,73 @@ fn malformed_profiles_are_rejected_without_panicking() {
|
|||
}
|
||||
}
|
||||
|
||||
// A press's full-K lightness depends on its ink and paper (newsprint can exceed L* 40).
|
||||
// Keep the relative paper-white check and require finite endpoints with darker, bounded ink.
|
||||
fn cmyk_endpoints_valid(paper: [f64; 3], black: [f64; 3]) -> bool {
|
||||
// Allow only float rounding at the physical L* limits, not a different printing condition.
|
||||
let lightness = -1e-4..=100.0 + 1e-4;
|
||||
paper.iter().chain(&black).all(|v| v.is_finite())
|
||||
&& lightness.contains(&paper[0])
|
||||
&& lightness.contains(&black[0])
|
||||
&& paper[0] > 90.0
|
||||
&& black[0] < paper[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_light_black_cmyk_lut_has_the_known_relative_ramp() {
|
||||
use photocraft_cms::Curve;
|
||||
use photocraft_cms::pipeline::Stage;
|
||||
use photocraft_cms::profile::{Lut, LutKind};
|
||||
|
||||
let mut p = cmyk().clone();
|
||||
// Independent linear test model: L* = 100 - 52 K, neutral a*/b*. Exercise A2B1 only;
|
||||
// the inherited coated profile's inverse is not an inverse of this generated model.
|
||||
p.a2b = [None, None, None];
|
||||
p.b2a = [None, None, None];
|
||||
p.a2b[1] = Some(Lut {
|
||||
kind: LutKind::Ab,
|
||||
inputs: 4,
|
||||
outputs: 3,
|
||||
stages: vec![
|
||||
Stage::Curves(vec![Curve::Identity; 4]),
|
||||
Stage::Clut(Clut::sample(vec![2; 4], 3, |ink, out| {
|
||||
out.copy_from_slice(&enc([100.0 - 52.0 * f64::from(ink[3]), 0.0, 0.0]));
|
||||
})),
|
||||
Stage::Curves(vec![Curve::Identity; 3]),
|
||||
],
|
||||
});
|
||||
let p = Profile::parse(&p.with_encoded_bytes().to_bytes()).unwrap();
|
||||
let paper = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric);
|
||||
let black = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric);
|
||||
assert!(cmyk_endpoints_valid(paper, black), "{paper:?} {black:?}");
|
||||
// Explicit expected values, not the validation predicate: also catch wrong channels,
|
||||
// intent selection, constant output and incorrect LUT encoding/interpolation.
|
||||
for (k, expected) in [(0.0, 100.0), (0.25, 87.0), (0.5, 74.0), (0.75, 61.0), (1.0, 48.0)] {
|
||||
let actual = to_lab(&p, &[0.0, 0.0, 0.0, k], Intent::RelativeColorimetric);
|
||||
// The writer quantizes the CLUT to 16 bits: at most 0.000763 L* error.
|
||||
assert!((actual[0] - expected).abs() < 0.001 && actual[1].abs() < 1e-4 && actual[2].abs() < 1e-4, "K={k}: {actual:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_endpoints_reject_invalid_values() {
|
||||
let paper = [100.0, 0.0, 0.0];
|
||||
let black = [48.0, 0.0, 0.0];
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
for channel in 0..3 {
|
||||
let mut p = paper;
|
||||
let mut k = black;
|
||||
p[channel] = bad;
|
||||
k[channel] = bad;
|
||||
assert!(!cmyk_endpoints_valid(p, black));
|
||||
assert!(!cmyk_endpoints_valid(paper, k));
|
||||
}
|
||||
}
|
||||
for (p, k) in [(90.0, 48.0), (100.01, 48.0), (100.0, -0.01), (100.0, 100.01), (100.0, 100.0), (99.0, 100.0)] {
|
||||
assert!(!cmyk_endpoints_valid([p, 0.0, 0.0], [k, 0.0, 0.0]), "L*: paper {p}, black {k}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses every profile the OS ships (macOS ColorSync; read-only, nothing is copied) and
|
||||
/// converts through each.
|
||||
#[test]
|
||||
|
|
@ -500,7 +567,7 @@ fn system_profiles() {
|
|||
// Real CMYK profile: paper lighter than 100 % K.
|
||||
let w = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric);
|
||||
let k = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric);
|
||||
assert!(w[0] > 90.0 && k[0] < 40.0, "{}: {w:?} {k:?}", path.display());
|
||||
assert!(cmyk_endpoints_valid(w, k), "{}: {w:?} {k:?}", path.display());
|
||||
// And it round-trips through its own BToA.
|
||||
if p.b2a[1].is_some() {
|
||||
let to = Transform::new(lab(), &p, Intent::RelativeColorimetric, false).unwrap();
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ heif = ["dep:photocraft-heif"]
|
|||
thiserror.workspace = true
|
||||
image = { workspace = true, features = ["gif", "bmp", "tga", "ico", "qoi", "hdr"] }
|
||||
png = "0.18"
|
||||
tiff = { version = "0.11", default-features = false, features = ["deflate", "lzw"] }
|
||||
tiff = { version = "0.11", default-features = false, features = ["deflate", "fax", "jpeg", "lzw"] }
|
||||
exr = { version = "1.73", default-features = false }
|
||||
half = "2"
|
||||
serde_json.workspace = true
|
||||
|
|
@ -36,6 +36,8 @@ photocraft-heif = { path = "../heif", optional = true }
|
|||
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rayon.workspace = true
|
||||
# EXR block (de)compression runs on the rayon pool natively; wasm stays single-threaded.
|
||||
exr = { version = "1.73", default-features = false, features = ["rayon"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ the encode plan, and `fidelity_warnings` reports the conversion when it loses in
|
|||
`Image::convert` applies the same ICC rule: it keeps the profile only when the colour model stays
|
||||
the same.
|
||||
|
||||
Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded table so
|
||||
`photocraft-io` can open them in Indexed Color mode.
|
||||
|
||||
## Documented asymmetries and limitations
|
||||
|
||||
* **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3`
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ cargo-fuzz = true
|
|||
libfuzzer-sys = "0.4"
|
||||
photocraft-codecs = { path = "..", features = ["heif"] }
|
||||
# Called directly too: the codec turns its panics into errors (see decode_heif.rs).
|
||||
heic-rs = "=0.1.1"
|
||||
heic-rs = "=0.1.4"
|
||||
|
||||
[profile.release]
|
||||
debug = 1
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ fn map_exr(e: exr::error::Error) -> CodecError {
|
|||
|
||||
/// Read the headers without the `exr` crate's validation (it rejects deep parts outright),
|
||||
/// after checking every part's declared size against the limits, before any pixel allocation.
|
||||
fn read_meta(bytes: &[u8], limits: &Limits) -> Result<MetaData, CodecError> {
|
||||
pub(super) fn read_meta(bytes: &[u8], limits: &Limits) -> Result<MetaData, CodecError> {
|
||||
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?;
|
||||
// Flat decoding reads every part at once, so the parts' sizes are also capped together.
|
||||
let mut total: u64 = 0;
|
||||
|
|
@ -93,12 +93,31 @@ fn pick_part(meta: &MetaData) -> usize {
|
|||
best
|
||||
}
|
||||
|
||||
/// Decode the flat layers via the `exr` crate (it handles every flat compression) and
|
||||
/// return the requested part's layer.
|
||||
fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels<FlatSamples>>, CodecError> {
|
||||
let image =
|
||||
read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(Cursor::new(bytes)).map_err(map_exr)?;
|
||||
image.layer_data.into_iter().nth(index).ok_or_else(|| err(format!("part {index} is missing from the file")))
|
||||
/// Selects one part for the `exr` crate's first-valid-layer reader: `create_channels_reader`
|
||||
/// is asked about the headers in file order and the first `Ok` wins, so every header before
|
||||
/// `index` (and, harmlessly, those after it) is declined. Only the chosen part's chunks are
|
||||
/// then decompressed and only its planes allocated; the other parts are skipped unread.
|
||||
struct OnlyPart<C> {
|
||||
inner: C,
|
||||
index: usize,
|
||||
seen: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
impl<'s, C: ReadChannels<'s>> ReadChannels<'s> for OnlyPart<C> {
|
||||
type Reader = C::Reader;
|
||||
|
||||
fn create_channels_reader(&'s self, header: &exr::meta::header::Header) -> exr::error::Result<Self::Reader> {
|
||||
let position = self.seen.get();
|
||||
self.seen.set(position.saturating_add(1));
|
||||
if position == self.index { self.inner.create_channels_reader(header) } else { Err(exr::error::Error::Invalid("part not requested".into())) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one part's flat layer via the `exr` crate (it handles every flat compression).
|
||||
/// Other parts are not decompressed, so an AOV file costs one part per request, not the file.
|
||||
pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels<FlatSamples>>, CodecError> {
|
||||
let only = OnlyPart { inner: read().no_deep_data().largest_resolution_level().all_channels(), index, seen: std::cell::Cell::new(0) };
|
||||
only.first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map(|image| image.layer_data).map_err(map_exr)
|
||||
}
|
||||
|
||||
fn layer_to_image(layer: &Layer<AnyChannels<FlatSamples>>) -> Result<Image, CodecError> {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ use std::io::Cursor;
|
|||
|
||||
use exr::meta::MetaData;
|
||||
use exr::meta::attribute::AttributeValue;
|
||||
use exr::prelude::{ReadChannels, ReadLayers, read};
|
||||
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
|
|
@ -200,7 +199,8 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
|
|||
.iter()
|
||||
.find(|l| l.name.eq_ignore_ascii_case(layer_name))
|
||||
.ok_or_else(|| CodecError::unsupported(F, format!("no Cryptomatte layer named {layer_name}")))?;
|
||||
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(|e| CodecError::malformed(F, e))?;
|
||||
// Also caps all parts' declared sizes together, like every other flat decode.
|
||||
let meta = super::exr::read_meta(bytes, limits)?;
|
||||
if meta.requirements.has_deep_data {
|
||||
return Err(CodecError::unsupported(F, "this file has deep parts; Cryptomatte decoding stays with flat files"));
|
||||
}
|
||||
|
|
@ -214,13 +214,7 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result<
|
|||
let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(8).saturating_add(std::mem::size_of::<Vec<(f32, f32)>>() as u64);
|
||||
limits.check_bytes(w32, h32, per_pixel)?;
|
||||
|
||||
let image = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(Cursor::new(bytes)).map_err(
|
||||
|e| match e {
|
||||
exr::error::Error::NotSupported(m) => CodecError::unsupported(F, format!("not supported: {m}")),
|
||||
e => CodecError::malformed(F, e),
|
||||
},
|
||||
)?;
|
||||
let ex_layer = image.layer_data.get(layer.part).ok_or_else(|| CodecError::malformed(F, "the Cryptomatte part is missing"))?;
|
||||
let ex_layer = super::exr::read_layer(bytes, layer.part)?;
|
||||
|
||||
let mut ids: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
|
||||
let mut coverages: Vec<Vec<f32>> = vec![Vec::new(); layer.channels.len()];
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ pub(crate) mod fits;
|
|||
pub(crate) mod gif_frames;
|
||||
pub(crate) mod heif;
|
||||
pub(crate) mod jpeg;
|
||||
pub(crate) mod pcx;
|
||||
pub(crate) mod png;
|
||||
pub(crate) mod pnm;
|
||||
pub(crate) mod tiff;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
//! ZSoft PCX (Paintbrush) reader: strictly bounded scanline RLE, 16/256-colour
|
||||
//! palettes and 8-bit three-plane RGB. Read-only: modern saves should use PNG.
|
||||
|
||||
use crate::SampleType;
|
||||
use crate::error::CodecError;
|
||||
use crate::format::Format;
|
||||
use crate::image::{ChannelLayout, Image};
|
||||
use crate::options::Limits;
|
||||
|
||||
const HEADER: usize = 128;
|
||||
|
||||
fn bad(reason: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(Format::Pcx, reason)
|
||||
}
|
||||
fn le16(b: &[u8], i: usize) -> u16 {
|
||||
u16::from_le_bytes([b[i], b[i + 1]])
|
||||
}
|
||||
|
||||
/// PCX lacks a strong magic number. Require plausible complete header fields before
|
||||
/// identifying it, rather than classifying every file starting with 0x0a as PCX.
|
||||
pub(crate) fn looks_like_pcx(b: &[u8]) -> bool {
|
||||
if b.len() < HEADER || b[0] != 0x0a || !matches!(b[1], 0 | 2 | 3 | 5) || b[2] != 1 {
|
||||
return false;
|
||||
}
|
||||
let bits = b[3];
|
||||
let planes = b[65];
|
||||
let stride = u32::from(le16(b, 66));
|
||||
let (x0, y0, x1, y1) = (le16(b, 4), le16(b, 6), le16(b, 8), le16(b, 10));
|
||||
if x1 < x0 || y1 < y0 || !matches!(bits, 1 | 2 | 4 | 8) || !(1..=4).contains(&planes) {
|
||||
return false;
|
||||
}
|
||||
let width = u32::from(x1 - x0) + 1;
|
||||
let min_stride = (width * u32::from(bits)).div_ceil(8);
|
||||
stride >= min_stride && stride > 0 && (bits != 8 || matches!(planes, 1 | 3)) && (bits == 8 || u32::from(bits) * u32::from(planes) <= 4)
|
||||
}
|
||||
|
||||
/// Read one compressed plane's row. RLE packets never cross a scanline.
|
||||
fn read_row(b: &[u8], pos: &mut usize, out: &mut [u8]) -> Result<(), CodecError> {
|
||||
let mut n = 0;
|
||||
while n < out.len() {
|
||||
let code = *b.get(*pos).ok_or_else(|| bad("truncated pixel RLE"))?;
|
||||
*pos += 1;
|
||||
let (count, value) = if code >= 0xc0 {
|
||||
let count = usize::from(code & 0x3f);
|
||||
let value = *b.get(*pos).ok_or_else(|| bad("truncated RLE packet"))?;
|
||||
*pos += 1;
|
||||
(count, value)
|
||||
} else {
|
||||
(1, code)
|
||||
};
|
||||
if count == 0 || count > out.len() - n {
|
||||
return Err(bad("RLE packet crosses a scanline or has zero length"));
|
||||
}
|
||||
out[n..n + count].fill(value);
|
||||
n += count;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
if !looks_like_pcx(b) {
|
||||
return Err(bad("invalid PCX header or unsupported pixel layout"));
|
||||
}
|
||||
let (w, h) = (u32::from(le16(b, 8) - le16(b, 4)) + 1, u32::from(le16(b, 10) - le16(b, 6)) + 1);
|
||||
limits.check(w, h, ChannelLayout::Rgb, SampleType::U8)?;
|
||||
let (bits, planes, stride) = (usize::from(b[3]), usize::from(b[65]), usize::from(le16(b, 66)));
|
||||
let row_len = stride.checked_mul(planes).ok_or_else(|| bad("row size overflow"))?;
|
||||
let total = (w as usize).checked_mul(h as usize).and_then(|n| n.checked_mul(3)).ok_or_else(|| bad("pixel size overflow"))?;
|
||||
// The last 769 bytes are the optional VGA palette for 8-bit indexed images.
|
||||
let vga = bits == 8 && planes == 1 && b.len() >= HEADER + 769 && b[b.len() - 769] == 0x0c;
|
||||
if bits == 8 && planes == 1 && !vga && le16(b, 68) != 2 {
|
||||
return Err(bad("8-bit indexed PCX has no 256-colour palette"));
|
||||
}
|
||||
let image_end = if vga { b.len() - 769 } else { b.len() };
|
||||
let mut row = vec![0u8; row_len];
|
||||
let mut pixels = vec![0u8; total];
|
||||
let mut p = HEADER;
|
||||
let width = w as usize;
|
||||
for y in 0..h as usize {
|
||||
for plane in row.chunks_mut(stride) {
|
||||
read_row(&b[..image_end], &mut p, plane)?;
|
||||
}
|
||||
for x in 0..width {
|
||||
let rgb = if bits == 8 && planes == 3 {
|
||||
[row[x], row[stride + x], row[2 * stride + x]]
|
||||
} else {
|
||||
let index = if bits == 8 {
|
||||
usize::from(row[x])
|
||||
} else {
|
||||
let bit = x * bits;
|
||||
let shift = 8 - bits - (bit % 8);
|
||||
let mask = (1usize << bits) - 1;
|
||||
(0..planes).fold(0usize, |v, plane| {
|
||||
let sample = (usize::from(row[plane * stride + bit / 8]) >> shift) & mask;
|
||||
v | sample << (plane * bits)
|
||||
})
|
||||
};
|
||||
if vga {
|
||||
let o = image_end + 1 + index * 3;
|
||||
[b[o], b[o + 1], b[o + 2]]
|
||||
} else if bits == 8 {
|
||||
let g = index as u8;
|
||||
[g, g, g] // palette-info 2: gray scale
|
||||
} else {
|
||||
let o = 16 + index * 3;
|
||||
[b[o], b[o + 1], b[o + 2]]
|
||||
}
|
||||
};
|
||||
pixels[(y * width + x) * 3..(y * width + x + 1) * 3].copy_from_slice(&rgb);
|
||||
}
|
||||
}
|
||||
let mut image = Image::from_u8(w, h, ChannelLayout::Rgb, pixels)?;
|
||||
let (xdpi, ydpi) = (le16(b, 12), le16(b, 14));
|
||||
if xdpi > 0 && ydpi > 0 {
|
||||
image.meta.dpi = Some((f32::from(xdpi), f32::from(ydpi)));
|
||||
}
|
||||
Ok(image)
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ use crate::Format;
|
|||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits, PngCompression};
|
||||
use crate::options::{EncodeOptions, Limits, PngCompression, alloc_zeroed};
|
||||
|
||||
const F: Format = Format::Png;
|
||||
pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp";
|
||||
|
|
@ -29,6 +29,10 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
let (w, h) = info.size();
|
||||
// Worst case output: 4 channels x 16 bit.
|
||||
limits.check_bytes(w, h, 8)?;
|
||||
// zlib never beats 1032:1; refuse a file too small for the declared image before the
|
||||
// output buffer exists. Measured on the stored samples (before palette/bit expansion).
|
||||
let stored = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(info.bits_per_pixel() as u64).div_ceil(8);
|
||||
Limits::check_plausible(stored, bytes.len(), 1100)?;
|
||||
}
|
||||
let mut reader = decoder.read_info().map_err(map_png_err)?;
|
||||
// APNG: the IDAT image is the first frame, or an extra default image when no fcTL precedes it.
|
||||
|
|
@ -47,7 +51,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
d => return Err(err(format!("unexpected output bit depth {d:?}"))),
|
||||
};
|
||||
let size = reader.output_buffer_size().ok_or_else(|| err("image too large"))?;
|
||||
let mut buf = vec![0u8; size];
|
||||
let mut buf = alloc_zeroed::<u8>(size)?;
|
||||
let out = reader.next_frame(&mut buf).map_err(map_png_err)?;
|
||||
buf.truncate(out.buffer_size());
|
||||
// Collect ancillary chunks after IDAT. Tolerate malformed tails once pixels
|
||||
|
|
|
|||
|
|
@ -145,6 +145,14 @@ impl<'a> File<'a> {
|
|||
Some(if self.le { u64::from_le_bytes(s) } else { u64::from_be_bytes(s) })
|
||||
}
|
||||
|
||||
/// The declared entry count of the directory at `at`, before its table is parsed.
|
||||
pub fn entry_count(&self, at: u64) -> Option<u64> {
|
||||
if at < self.header_len() {
|
||||
return None;
|
||||
}
|
||||
if self.big { self.u64_at(at) } else { self.u16_at(at).map(u64::from) }
|
||||
}
|
||||
|
||||
/// The directory at `at`: its whole table, up to and including the next-IFD pointer, must
|
||||
/// be inside the file and after the header.
|
||||
pub fn ifd(&self, at: u64) -> Option<Ifd> {
|
||||
|
|
@ -355,6 +363,10 @@ struct Walk<'a, 'f> {
|
|||
seen: HashSet<u64>,
|
||||
pages: Vec<TiffPage>,
|
||||
complete: bool,
|
||||
/// Directory entries that may still be parsed. Real directory tables are disjoint, so their
|
||||
/// total size is bounded by the file; overlapping tables (a hostile file) would otherwise
|
||||
/// make every one of thousands of directories cost a full 65,535-entry parse.
|
||||
entry_budget: u64,
|
||||
}
|
||||
|
||||
impl Walk<'_, '_> {
|
||||
|
|
@ -366,6 +378,15 @@ impl Walk<'_, '_> {
|
|||
self.complete = false;
|
||||
return;
|
||||
}
|
||||
let Some(n) = self.f.entry_count(at) else {
|
||||
self.complete = false;
|
||||
return;
|
||||
};
|
||||
if n > self.entry_budget {
|
||||
self.complete = false;
|
||||
return;
|
||||
}
|
||||
self.entry_budget -= n;
|
||||
let Some(ifd) = self.f.ifd(at) else {
|
||||
self.complete = false;
|
||||
return;
|
||||
|
|
@ -391,10 +412,37 @@ pub fn tiff_info(bytes: &[u8]) -> Result<TiffInfo, CodecError> {
|
|||
}
|
||||
|
||||
pub(crate) fn info_of(f: &File<'_>) -> Result<TiffInfo, CodecError> {
|
||||
let mut w = Walk { f, seen: HashSet::new(), pages: Vec::new(), complete: true };
|
||||
let mut w = Walk { f, seen: HashSet::new(), pages: Vec::new(), complete: true, entry_budget: (f.b.len() as u64) / 12 + 64 };
|
||||
w.chain(f.first_ifd, None, 0);
|
||||
if w.pages.is_empty() {
|
||||
return Err(CodecError::malformed(Format::Tiff, "the first image directory is missing or cut off"));
|
||||
}
|
||||
Ok(TiffInfo { big_tiff: f.big, little_endian: f.le, pages: w.pages, complete: w.complete })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 10,000 directories 4 bytes apart, each declaring 65,535 overlapping entries: the walk must
|
||||
/// stop after the file-size entry budget instead of parsing ~6.5e8 entries.
|
||||
#[test]
|
||||
fn overlapping_directory_tables_exhaust_the_entry_budget() {
|
||||
let first = 8usize;
|
||||
let table = 2 + 65_535 * 12 + 4;
|
||||
let mut b = vec![0u8; first + table + 4 * 10_000];
|
||||
b[..8].copy_from_slice(&[b'I', b'I', 42, 0, first as u8, 0, 0, 0]);
|
||||
for i in 0..10_000usize {
|
||||
let at = first + 4 * i;
|
||||
b[at..at + 2].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
let next_cell = at + 2 + 65_535 * 12;
|
||||
let next = (first + 4 * (i + 1)) as u32;
|
||||
if let Some(cell) = b.get_mut(next_cell..next_cell + 4) {
|
||||
cell.copy_from_slice(&next.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let info = tiff_info(&b).expect("the first directory is readable");
|
||||
assert!(!info.complete);
|
||||
assert!(info.pages.len() < 200, "{} directories parsed", info.pages.len());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ fn map_err(f: Format, e: image::ImageError) -> CodecError {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
|
||||
let decoder = image::codecs::bmp::BmpDecoder::new(Cursor::new(bytes)).map_err(|e| map_err(Format::Bmp, e))?;
|
||||
Ok(decoder.get_palette().map(|palette| palette.to_vec()))
|
||||
}
|
||||
|
||||
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?;
|
||||
let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use crate::Format;
|
|||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits};
|
||||
use crate::options::{EncodeOptions, Limits, alloc_zeroed};
|
||||
|
||||
const F: Format = Format::WebP;
|
||||
|
||||
|
|
@ -23,8 +23,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
|||
let (w, h) = dec.dimensions();
|
||||
let layout = if dec.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
|
||||
limits.check(w, h, layout, SampleType::U8)?;
|
||||
// VP8L back-references and VP8 skipped macroblocks compress flat areas extremely well, so the
|
||||
// ratio is generous; it still stops a header-only file from asking for a gigabyte.
|
||||
Limits::check_plausible(u64::from(w) * u64::from(h) * layout.channels() as u64, bytes.len(), 1 << 16)?;
|
||||
let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?;
|
||||
let mut buf = vec![0u8; size];
|
||||
let mut buf = alloc_zeroed::<u8>(size)?;
|
||||
dec.read_image(&mut buf).map_err(err)?;
|
||||
let icc = dec.icc_profile().ok().flatten();
|
||||
let exif = dec.exif_metadata().ok().flatten();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ pub enum Format {
|
|||
Ico,
|
||||
/// Netpbm family: PBM/PGM/PPM (P1–P6), PAM (P7) and PFM (`PF`/`Pf`).
|
||||
Pnm,
|
||||
/// ZSoft Paintbrush: 1/2/4-bit planar palettes, 8-bit indexed and 24-bit RGB.
|
||||
Pcx,
|
||||
Qoi,
|
||||
OpenExr,
|
||||
/// Radiance RGBE.
|
||||
|
|
@ -56,6 +58,10 @@ pub struct FormatCaps {
|
|||
/// Formats that are enabled but intentionally not symmetric, with the reason.
|
||||
/// The test-suite asserts that every other enabled format is read+write.
|
||||
pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
|
||||
(
|
||||
Format::Pcx,
|
||||
"PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.",
|
||||
),
|
||||
(
|
||||
Format::Avif,
|
||||
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
|
||||
|
|
@ -78,7 +84,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C
|
|||
|
||||
impl Format {
|
||||
/// Every format known to the crate (enabled or not).
|
||||
pub const ALL: [Format; 15] = [
|
||||
pub const ALL: [Format; 16] = [
|
||||
Format::Png,
|
||||
Format::Jpeg,
|
||||
Format::Tiff,
|
||||
|
|
@ -88,6 +94,7 @@ impl Format {
|
|||
Format::Tga,
|
||||
Format::Ico,
|
||||
Format::Pnm,
|
||||
Format::Pcx,
|
||||
Format::Qoi,
|
||||
Format::OpenExr,
|
||||
Format::Hdr,
|
||||
|
|
@ -117,6 +124,7 @@ impl Format {
|
|||
Format::Tga => "TGA",
|
||||
Format::Ico => "ICO",
|
||||
Format::Pnm => "Netpbm",
|
||||
Format::Pcx => "PCX",
|
||||
Format::Qoi => "QOI",
|
||||
Format::OpenExr => "OpenEXR",
|
||||
Format::Hdr => "Radiance HDR",
|
||||
|
|
@ -139,6 +147,7 @@ impl Format {
|
|||
Format::Tga => &["tga", "icb", "vda", "vst"],
|
||||
Format::Ico => &["ico"],
|
||||
Format::Pnm => &["pnm", "pbm", "pgm", "ppm", "pam", "pfm"],
|
||||
Format::Pcx => &["pcx"],
|
||||
Format::Qoi => &["qoi"],
|
||||
Format::OpenExr => &["exr"],
|
||||
Format::Hdr => &["hdr"],
|
||||
|
|
@ -159,6 +168,7 @@ impl Format {
|
|||
Format::Tga => "image/x-tga",
|
||||
Format::Ico => "image/x-icon",
|
||||
Format::Pnm => "image/x-portable-anymap",
|
||||
Format::Pcx => "image/x-pcx",
|
||||
Format::Qoi => "image/qoi",
|
||||
Format::OpenExr => "image/x-exr",
|
||||
Format::Hdr => "image/vnd.radiance",
|
||||
|
|
@ -207,6 +217,7 @@ pub fn caps(format: Format) -> FormatCaps {
|
|||
Format::Tga => FormatCaps { layouts: RGB_GRAY, ..base },
|
||||
Format::Ico => FormatCaps { layouts: &[L::Rgba], ..base },
|
||||
Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base },
|
||||
Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base },
|
||||
Format::Qoi => base,
|
||||
Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base },
|
||||
Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base },
|
||||
|
|
@ -266,6 +277,9 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
|
|||
if b.starts_with(b"SIMPLE =") {
|
||||
return Some(Format::Fits);
|
||||
}
|
||||
if crate::codecs::pcx::looks_like_pcx(b) {
|
||||
return Some(Format::Pcx);
|
||||
}
|
||||
if looks_like_tga(b) {
|
||||
return Some(Format::Tga);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -512,6 +512,8 @@ impl Image {
|
|||
out.extend_from_slice(&one);
|
||||
}
|
||||
out
|
||||
} else if let Some(fast) = self.convert_int_direct(layout, sample) {
|
||||
fast
|
||||
} else if layout == self.layout {
|
||||
quantize(&self.to_normalized(), sample)
|
||||
} else {
|
||||
|
|
@ -531,6 +533,55 @@ impl Image {
|
|||
}
|
||||
}
|
||||
|
||||
/// Integer fast path of [`Image::convert`] for U8/U16 sources and targets whose result is
|
||||
/// a pure channel copy: same colour model (alpha added as opaque, or dropped) or gray to
|
||||
/// RGB. Bit-identical to the generic f32 route (see the equivalence tests) but without
|
||||
/// the two intermediate f32 buffers. Returns `None` for everything else.
|
||||
fn convert_int_direct(&self, layout: ChannelLayout, sample: SampleType) -> Option<Vec<u8>> {
|
||||
let int = |s: SampleType| matches!(s, SampleType::U8 | SampleType::U16);
|
||||
if !int(self.sample) || !int(sample) {
|
||||
return None;
|
||||
}
|
||||
let gray_to_rgb = self.layout.is_gray() && layout.is_rgb();
|
||||
if !gray_to_rgb && !layout.same_model(self.layout) {
|
||||
return None;
|
||||
}
|
||||
let one: &[u8] = match sample {
|
||||
SampleType::U8 => &[u8::MAX],
|
||||
_ => &u16::MAX.to_ne_bytes(),
|
||||
};
|
||||
let (fc, tc) = (self.layout.channels(), layout.channels());
|
||||
let (sb, db) = (self.sample.bytes(), sample.bytes());
|
||||
let (src_color, dst_color) = (self.layout.color_channels(), layout.color_channels());
|
||||
let n = self.data.len() / (fc * sb);
|
||||
let mut out = Vec::with_capacity(n.checked_mul(tc)?.checked_mul(db)?);
|
||||
macro_rules! run {
|
||||
($conv:expr) => {{
|
||||
let conv = $conv;
|
||||
for px in self.data.chunks_exact(fc * sb) {
|
||||
for j in 0..dst_color {
|
||||
let c = if src_color == 1 { 0 } else { j };
|
||||
conv(&px[c * sb..(c + 1) * sb], &mut out);
|
||||
}
|
||||
if layout.has_alpha() {
|
||||
if self.layout.has_alpha() {
|
||||
conv(&px[src_color * sb..(src_color + 1) * sb], &mut out);
|
||||
} else {
|
||||
out.extend_from_slice(one);
|
||||
}
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
match (self.sample, sample) {
|
||||
(SampleType::U8, SampleType::U8) => run!(|s: &[u8], o: &mut Vec<u8>| o.push(s[0])),
|
||||
(SampleType::U16, SampleType::U16) => run!(|s: &[u8], o: &mut Vec<u8>| o.extend_from_slice(s)),
|
||||
(SampleType::U8, SampleType::U16) => run!(|s: &[u8], o: &mut Vec<u8>| o.extend_from_slice(&(u16::from(s[0]) * 257).to_ne_bytes())),
|
||||
_ => run!(|s: &[u8], o: &mut Vec<u8>| o.push(((u32::from(u16::from_ne_bytes([s[0], s[1]])) + 128) / 257) as u8)),
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// [`Image::convert`], borrowing `self` when it already has that layout and sample type
|
||||
/// (encoders use it so a full-size image isn't copied for nothing).
|
||||
pub fn converted(&self, layout: ChannelLayout, sample: SampleType) -> std::borrow::Cow<'_, Image> {
|
||||
|
|
@ -661,6 +712,66 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
|
||||
/// The generic f32 route `convert` used before the integer fast paths (the reference).
|
||||
fn convert_via_f32(img: &Image, layout: ChannelLayout, sample: SampleType) -> Vec<u8> {
|
||||
quantize(&convert_layout(&img.to_normalized(), img.layout, layout), sample)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn int_fast_path_matches_f32_route_for_every_value() {
|
||||
// Every U8 and every U16 value through every depth pair (alpha goes through the same per-sample conversion; covered by the layout test).
|
||||
let all8: Vec<u8> = (0..=255).collect();
|
||||
let all16: Vec<u16> = (0..=65535).collect();
|
||||
for (src, to) in
|
||||
[(SampleType::U8, SampleType::U8), (SampleType::U8, SampleType::U16), (SampleType::U16, SampleType::U8), (SampleType::U16, SampleType::U16)]
|
||||
{
|
||||
let img = if src == SampleType::U8 {
|
||||
Image::from_u8(256, 1, ChannelLayout::Gray, all8.clone()).unwrap()
|
||||
} else {
|
||||
Image::from_u16(65536, 1, ChannelLayout::Gray, &all16).unwrap()
|
||||
};
|
||||
for layout in [ChannelLayout::Gray, ChannelLayout::Rgb] {
|
||||
assert_eq!(img.convert(layout, to).data(), &convert_via_f32(&img, layout, to)[..], "{src:?}->{to:?} {layout:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn int_fast_path_matches_f32_route_for_every_layout() {
|
||||
let mut seed = 0x9e3779b97f4a7c15u64;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed
|
||||
};
|
||||
for from_s in [SampleType::U8, SampleType::U16] {
|
||||
for from_l in ChannelLayout::ALL {
|
||||
let n = 64 * from_l.channels();
|
||||
let mut vals = Vec::new();
|
||||
for i in 0..n {
|
||||
// Edge values (0, 1, max-1, max, mid) mixed with random ones.
|
||||
let r = next();
|
||||
let v = match i % 7 {
|
||||
0 => 0.0,
|
||||
1 => 1.0,
|
||||
2 => 1.0 / 255.0,
|
||||
3 => 254.0 / 255.0,
|
||||
_ => (r % 65536) as f32 / 65535.0,
|
||||
};
|
||||
vals.push(v);
|
||||
}
|
||||
let img = Image::from_normalized(64, 1, from_l, from_s, &vals).unwrap();
|
||||
for to_l in ChannelLayout::ALL {
|
||||
for to_s in SampleType::ALL {
|
||||
let got = img.convert(to_l, to_s);
|
||||
assert_eq!(got.data(), &convert_via_f32(&img, to_l, to_s)[..], "{from_l:?}/{from_s:?} -> {to_l:?}/{to_s:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u8_u16_roundtrip_exact() {
|
||||
let v: Vec<u8> = (0..=255).collect();
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_
|
|||
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
|
||||
pub use half::f16;
|
||||
|
||||
use crate::codecs::{exr, fits, heif, jpeg, png, pnm, tiff, via_image, webp};
|
||||
use crate::codecs::{exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp};
|
||||
|
||||
/// Detect the format and decode with default [`Limits`].
|
||||
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
|
||||
|
|
@ -74,6 +74,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
|||
Format::Tiff => tiff::decode(bytes, l),
|
||||
Format::WebP => webp::decode(bytes, l),
|
||||
Format::Pnm => pnm::decode(bytes, l),
|
||||
Format::Pcx => pcx::decode(bytes, l),
|
||||
Format::Fits => fits::decode(bytes, l),
|
||||
Format::OpenExr => exr::decode(bytes, l),
|
||||
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
|
||||
|
|
@ -97,6 +98,11 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
|
|||
finish_decode(img, o, opts)
|
||||
}
|
||||
|
||||
/// Returns the embedded colour table for an indexed BMP, if it has one.
|
||||
pub fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
|
||||
via_image::bmp_palette(bytes)
|
||||
}
|
||||
|
||||
/// Lists the pages (image directories) of a TIFF or BigTIFF file without decoding pixels: the
|
||||
/// main IFD chain and its SubIFDs, each with its size, kind (page, reduced-resolution copy or
|
||||
/// mask) and storage. [`TiffInfo::default_page`] is the page [`decode`] opens.
|
||||
|
|
@ -216,6 +222,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
|
|||
Format::Tiff => tiff::encode(image, plan, opts),
|
||||
Format::WebP => webp::encode(image, plan, opts),
|
||||
Format::Pnm => pnm::encode(image, plan, opts),
|
||||
Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")),
|
||||
Format::Fits => fits::encode(image, plan, opts),
|
||||
Format::OpenExr => exr::encode(image, plan, opts),
|
||||
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ pub struct Limits {
|
|||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
// 2^30 pixels (e.g. 32768²) and, on 64-bit targets, 8 GiB: enough for the large
|
||||
// documents Photoshop users open (a 20000² RGBA 16-bit image is 3.2 GB).
|
||||
let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 };
|
||||
// documents Photoshop users open (a 20000² RGBA 16-bit image is 3.2 GB). On 32-bit
|
||||
// targets (wasm32 has a 4 GiB address space shared with the document, history and
|
||||
// the decoder's own buffers) 1 GiB is the most one buffer can safely ask for.
|
||||
let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 1 << 30 };
|
||||
Limits { max_width: 1 << 18, max_height: 1 << 18, max_pixels: 1 << 30, max_alloc }
|
||||
}
|
||||
}
|
||||
|
|
@ -60,6 +62,19 @@ impl Limits {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Decompression-bomb guard for formats that compress: refuse a file too small to hold
|
||||
/// `declared_bytes` of (stored, pre-expansion) image data, given that the compression
|
||||
/// never beats `max_ratio`:1. Called before the output buffer is allocated, so a
|
||||
/// header-only hostile file cannot make the decoder reserve gigabytes. A fixed 1 MiB of
|
||||
/// slack keeps tiny images and headers with large metadata unaffected.
|
||||
pub(crate) fn check_plausible(declared_bytes: u64, file_len: usize, max_ratio: u64) -> Result<(), CodecError> {
|
||||
let capacity = (file_len as u64).saturating_mul(max_ratio).saturating_add(1 << 20);
|
||||
if declared_bytes > capacity {
|
||||
return Err(CodecError::LimitExceeded(format!("a {file_len} byte file cannot hold {declared_bytes} bytes of image data")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn alloc_usize(&self) -> usize {
|
||||
usize::try_from(self.max_alloc).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
|
@ -149,3 +164,13 @@ impl Default for EncodeOptions {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A zero-filled buffer of `len` elements, allocated fallibly: an allocation failure becomes
|
||||
/// [`CodecError::LimitExceeded`] instead of aborting the process (which `catch_unwind` cannot
|
||||
/// stop).
|
||||
pub(crate) fn alloc_zeroed<T: Copy + Default>(len: usize) -> Result<Vec<T>, CodecError> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(len).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {len} elements for the image")))?;
|
||||
v.resize(len, T::default());
|
||||
Ok(v)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,3 +197,32 @@ fn parts_are_capped_together() {
|
|||
let e = exr_info(&aov_file(), &limits).unwrap_err().to_string();
|
||||
assert!(e.contains("across 3 parts"), "{e}");
|
||||
}
|
||||
|
||||
/// Two ZIP1 parts of 4x4 RGBA; every chunk of part 0 is then overwritten with garbage, so
|
||||
/// inflating it fails.
|
||||
fn file_with_corrupt_first_part() -> Vec<u8> {
|
||||
let px = |k: f32| -> Vec<(&'static str, Vec<f32>)> { ["R", "G", "B", "A"].iter().map(|c| (*c, vec![k; 16])).collect() };
|
||||
let mut bytes = write_image(vec![layer_f32("first", Compression::ZIP1, &px(0.25)), layer_f32("second", Compression::ZIP1, &px(0.75))]);
|
||||
let mut cursor = std::io::Cursor::new(&bytes[..]);
|
||||
exr::meta::MetaData::read_from_buffered(&mut cursor, false).unwrap();
|
||||
let table = cursor.position() as usize;
|
||||
// Part 0 owns the first four of the eight chunks (one scan line per ZIP1 block).
|
||||
for i in 0..4 {
|
||||
let at = table + i * 8;
|
||||
let offset = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) as usize;
|
||||
let size = u32::from_le_bytes(bytes[offset + 8..offset + 12].try_into().unwrap()) as usize;
|
||||
bytes[offset + 12..offset + 12 + size].fill(0xA5);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoding_one_part_does_not_decode_the_others() {
|
||||
let bytes = file_with_corrupt_first_part();
|
||||
// Part 0 is unreadable ...
|
||||
assert!(decode_exr_part(&bytes, 0, &DecodeOptions::default()).is_err());
|
||||
// ... but asking for part 1 must not touch it.
|
||||
let img = decode_exr_part(&bytes, 1, &DecodeOptions::default()).expect("part 1 decodes on its own");
|
||||
assert_eq!((img.width(), img.height()), (4, 4));
|
||||
assert!(img.to_normalized().iter().all(|v| near(*v, 0.75)), "wrong pixels");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,30 @@ fn png_bomb_header_rejected_before_alloc() {
|
|||
assert!(is_limit(decode(&b)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_header_at_the_default_limit_needs_a_plausible_file_size() {
|
||||
// 32768 x 32768 x RGBA16 is exactly the default max_alloc: it passes the dimension limits,
|
||||
// but a ~100 byte file cannot hold it (zlib tops out near 1032:1), so it is refused before
|
||||
// the 8 GiB output buffer is allocated.
|
||||
let mut ihdr = Vec::new();
|
||||
ihdr.extend_from_slice(&32768u32.to_be_bytes());
|
||||
ihdr.extend_from_slice(&32768u32.to_be_bytes());
|
||||
ihdr.extend_from_slice(&[16, 6, 0, 0, 0]);
|
||||
let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
b.extend(png_chunk(b"IHDR", &ihdr));
|
||||
b.extend(png_chunk(b"IDAT", &[0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]));
|
||||
b.extend(png_chunk(b"IEND", &[]));
|
||||
assert!(is_limit(decode(&b)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_highly_compressible_image_still_decodes() {
|
||||
let img = Image::from_u8(3000, 3000, ChannelLayout::Rgba, vec![255u8; 3000 * 3000 * 4]).unwrap();
|
||||
let b = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
|
||||
assert!(b.len() < 100_000, "{}", b.len());
|
||||
assert_eq!(decode(&b).unwrap().width(), 3000);
|
||||
}
|
||||
|
||||
const PNG_TEXT_BUDGET: usize = 64 << 10;
|
||||
|
||||
fn png_text_decode(bytes: &[u8], max_alloc: usize) -> Result<Image, CodecError> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
use photocraft_codecs::{ChannelLayout, CodecError, Format, SampleType, caps, decode, decode_as, detect, encode, from_extension};
|
||||
|
||||
fn header(bits: u8, planes: u8, width: u16, height: u16, stride: u16) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 128];
|
||||
b[0] = 0x0a; // ZSoft
|
||||
b[1] = 5; // PC Paintbrush 3.0+
|
||||
b[2] = 1; // RLE
|
||||
b[3] = bits;
|
||||
b[8..10].copy_from_slice(&(width - 1).to_le_bytes());
|
||||
b[10..12].copy_from_slice(&(height - 1).to_le_bytes());
|
||||
b[12..14].copy_from_slice(&300u16.to_le_bytes());
|
||||
b[14..16].copy_from_slice(&150u16.to_le_bytes());
|
||||
b[65] = planes;
|
||||
b[66..68].copy_from_slice(&stride.to_le_bytes());
|
||||
b[68..70].copy_from_slice(&1u16.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
fn rle_row(out: &mut Vec<u8>, row: &[u8]) {
|
||||
for &value in row {
|
||||
if value >= 0xc0 {
|
||||
out.extend_from_slice(&[0xc1, value]);
|
||||
} else {
|
||||
out.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcx_is_readable_but_not_writable() {
|
||||
assert_eq!(from_extension("archive.PCX"), Some(Format::Pcx));
|
||||
assert!(caps(Format::Pcx).read && !caps(Format::Pcx).write);
|
||||
let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgb, vec![0, 0, 0]).unwrap();
|
||||
assert!(matches!(encode(&img, Format::Pcx, &Default::default()), Err(CodecError::Unsupported { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_three_plane_rgb_and_keeps_dpi() {
|
||||
let mut b = header(8, 3, 2, 1, 2);
|
||||
for row in [&[255, 0][..], &[0, 255][..], &[0, 0][..]] {
|
||||
rle_row(&mut b, row);
|
||||
}
|
||||
assert_eq!(detect(&b), Some(Format::Pcx));
|
||||
let im = decode(&b).unwrap();
|
||||
assert_eq!(im.layout(), ChannelLayout::Rgb);
|
||||
assert_eq!(im.sample_type(), SampleType::U8);
|
||||
assert_eq!(im.dimensions(), (2, 1));
|
||||
assert_eq!(im.data(), &[255, 0, 0, 0, 255, 0]);
|
||||
assert_eq!(im.meta.dpi, Some((300.0, 150.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_256_colour_vga_palette() {
|
||||
let mut b = header(8, 1, 2, 1, 2);
|
||||
rle_row(&mut b, &[1, 2]);
|
||||
b.push(0x0c);
|
||||
let mut palette = vec![0; 256 * 3];
|
||||
palette[3..6].copy_from_slice(&[255, 0, 0]);
|
||||
palette[6..9].copy_from_slice(&[0, 255, 0]);
|
||||
b.extend(palette);
|
||||
assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_4bit_and_monochrome_palettes() {
|
||||
let mut four = header(4, 1, 2, 1, 2);
|
||||
four[16 + 3..16 + 6].copy_from_slice(&[255, 0, 0]);
|
||||
four[16 + 6..16 + 9].copy_from_slice(&[0, 0, 255]);
|
||||
rle_row(&mut four, &[0x12, 0]);
|
||||
assert_eq!(decode(&four).unwrap().data(), &[255, 0, 0, 0, 0, 255]);
|
||||
|
||||
let mut one = header(1, 1, 2, 1, 2);
|
||||
one[16 + 3..16 + 6].copy_from_slice(&[255, 0, 0]);
|
||||
rle_row(&mut one, &[0x80, 0]);
|
||||
assert_eq!(decode(&one).unwrap().data(), &[255, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_and_overshooting_rle_without_panicking() {
|
||||
let mut b = header(8, 3, 2, 1, 2);
|
||||
assert!(decode_as(Format::Pcx, &b).is_err(), "header alone is truncated");
|
||||
b.extend([0xc5, 0xff]); // a five-pixel run cannot cross a two-byte row
|
||||
assert!(decode_as(Format::Pcx, &b).is_err());
|
||||
let mut b = header(8, 3, 2, 1, 2);
|
||||
b.extend([0xc1]); // missing run value
|
||||
assert!(decode_as(Format::Pcx, &b).is_err());
|
||||
let mut b = header(8, 3, 2, 1, 2);
|
||||
b[66..68].copy_from_slice(&0u16.to_le_bytes());
|
||||
assert_eq!(detect(&b), None, "invalid stride is not a PCX signature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_headers_that_exceed_allocation_budget() {
|
||||
let mut b = header(8, 3, 3000, 3000, 3000);
|
||||
// The default decoder rejects the allocation before reading any scanline.
|
||||
let opts = photocraft_codecs::DecodeOptions { limits: photocraft_codecs::Limits { max_alloc: 1024, ..Default::default() }, ..Default::default() };
|
||||
assert!(matches!(photocraft_codecs::decode_as_with(Format::Pcx, &b, &opts), Err(CodecError::LimitExceeded(_))));
|
||||
b[2] = 0;
|
||||
assert_eq!(detect(&b), None, "unsupported uncompressed encoding");
|
||||
}
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
mod common;
|
||||
|
||||
use common::tiffgen::*;
|
||||
use photocraft_codecs::{Image, decode, exif_orientation, upright_exif};
|
||||
|
||||
fn classic_white_is_zero() -> Vec<u8> {
|
||||
|
|
@ -95,3 +98,55 @@ fn big_tiff_orientation_6_and_8_are_applied_to_decoded_pixels() {
|
|||
assert_eq!(image.data(), &upright, "orientation {orientation}");
|
||||
}
|
||||
}
|
||||
|
||||
fn with_compression(mut dir: Dir, code: u64) -> Dir {
|
||||
for entry in &mut dir.entries {
|
||||
if entry.0 == 259 {
|
||||
entry.2 = vec![V::N(code)];
|
||||
}
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_compressed_tiff_decodes() {
|
||||
// A flat mid-grey 16x16 image as a baseline JPEG stored in one strip (Compression = 7).
|
||||
let mut jpeg = Vec::new();
|
||||
let gray = vec![100u8; 16 * 16];
|
||||
jpeg_encoder::Encoder::new(&mut jpeg, 95).encode(&gray, 16, 16, jpeg_encoder::ColorType::Luma).expect("encode the JPEG strip");
|
||||
let mut dir = with_compression(strips(16, 16, 8, 1, 1, 16, &[0; 256]), 7);
|
||||
dir.chunks = vec![jpeg];
|
||||
let bytes = build(true, false, &[dir], &[0]).bytes;
|
||||
|
||||
let image = decode(&bytes).expect("a JPEG-compressed TIFF should decode");
|
||||
assert_eq!(image.dimensions(), (16, 16));
|
||||
assert!(image.data().iter().all(|&v| v.abs_diff(100) <= 4), "{:?}", image.data().get(..4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccitt_group4_tiff_decodes() {
|
||||
// 8x3 bilevel: a white row, then two black rows. Bits: V0 | H(white 0, black 8) | V0 V0 | EOFB.
|
||||
let bits = "1".to_string() + "001" + "00110101" + "000101" + "11" + "000000000001" + "000000000001";
|
||||
let mut data = Vec::new();
|
||||
for chunk in bits.as_bytes().chunks(8) {
|
||||
let mut byte = 0u8;
|
||||
for (i, c) in chunk.iter().enumerate() {
|
||||
if *c == b'1' {
|
||||
byte |= 0x80 >> i;
|
||||
}
|
||||
}
|
||||
data.push(byte);
|
||||
}
|
||||
let mut dir = with_compression(strips(8, 3, 1, 1, 0, 3, &[0; 3]), 4);
|
||||
dir.chunks = vec![data];
|
||||
let bytes = build(true, false, &[dir], &[0]).bytes;
|
||||
|
||||
let image = decode(&bytes).expect("a CCITT G4 TIFF should decode");
|
||||
assert_eq!(image.dimensions(), (8, 3));
|
||||
let d = image.data();
|
||||
assert_eq!(d.len(), 24, "{d:?}");
|
||||
assert!(d[..8].iter().all(|&v| v == d[0]), "{d:?}");
|
||||
assert!(d[8..].iter().all(|&v| v == d[8]), "{d:?}");
|
||||
assert_ne!(d[0], d[8], "{d:?}");
|
||||
assert_eq!(d[0], 255, "white row of a WhiteIsZero image");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
//! Formulas are documented approximations of Photoshop behaviour. Exact matching is tuned in
|
||||
//! milestone M7 against Photoshop-rendered PSD composites (the oracle in `testkit`).
|
||||
|
||||
use photocraft_color::SampleType;
|
||||
use photocraft_color::convert::{D50, SRGB_TO_XYZ_D50, XYZ_D50_TO_SRGB, mat3_mul, rgb_to_gray, srgb_to_linear};
|
||||
use photocraft_color::convert::{D50, SRGB_TO_XYZ_D50, XYZ_D50_TO_SRGB, lab_to_srgb, mat3_mul, rgb_to_gray, srgb_to_lab, srgb_to_linear};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::Adjustment;
|
||||
use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel, ToneSpace, lut3d_len};
|
||||
|
||||
|
|
@ -45,14 +45,16 @@ impl Transfer {
|
|||
t => t,
|
||||
}
|
||||
}
|
||||
fn decode(self, v: f32) -> f32 {
|
||||
/// Tone-encoded value → linear light.
|
||||
pub fn decode(self, v: f32) -> f32 {
|
||||
match self {
|
||||
Transfer::Srgb => photocraft_color::convert::srgb_to_linear(v.max(0.0)),
|
||||
Transfer::Gamma(1.0) => v.max(0.0),
|
||||
Transfer::Gamma(g) => v.max(0.0).powf(g),
|
||||
}
|
||||
}
|
||||
fn encode(self, v: f32) -> f32 {
|
||||
/// Linear light → tone-encoded value.
|
||||
pub fn encode(self, v: f32) -> f32 {
|
||||
match self {
|
||||
Transfer::Srgb => photocraft_color::convert::linear_to_srgb(v.max(0.0)),
|
||||
Transfer::Gamma(1.0) => v.max(0.0),
|
||||
|
|
@ -92,11 +94,8 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
|
|||
// 27.11, brightness +40 with contrast +30 maps 128 to 185 (brightness first), while
|
||||
// the corpus's brightness -23 with contrast -26 (psd-tools) needs contrast first;
|
||||
// contrast 80 keeps 128 at 128 (a 0.5 pivot moved it to 130).
|
||||
let b = brightness / 255.0;
|
||||
let c = contrast.clamp(-100.0, 99.0);
|
||||
let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 };
|
||||
let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) };
|
||||
map_rgb(buf, |px| px.map(|v| ((v + pre - LEGACY_PIVOT) * k + LEGACY_PIVOT + post).clamp(0.0, 1.0)))
|
||||
let f = legacy_brightness_contrast(*brightness, *contrast);
|
||||
map_rgb(buf, |px| px.map(&f))
|
||||
}
|
||||
Adjustment::BrightnessContrast { brightness, contrast, .. } => {
|
||||
// Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth
|
||||
|
|
@ -166,7 +165,8 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
|
|||
}
|
||||
None => (0.0, 0.0, 0.0),
|
||||
};
|
||||
hue_saturation(c, *hue + dh, (*saturation / 100.0 + ds).clamp(-1.0, 1.0), (*lightness / 100.0 + dl).clamp(-1.0, 1.0), *colorize)
|
||||
let out = hue_saturation(c, *hue + dh, (*saturation / 100.0 + ds).clamp(-1.0, 1.0), (*lightness / 100.0).clamp(-1.0, 1.0), *colorize);
|
||||
range_lightness(out, dl)
|
||||
})
|
||||
}
|
||||
Adjustment::Vibrance { vibrance, saturation } => {
|
||||
|
|
@ -191,7 +191,9 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
|
|||
Adjustment::BlackWhite { weights, tint } => map_rgb(buf, |c| {
|
||||
let g = black_white_gray(c, weights);
|
||||
match tint {
|
||||
Some(t) => std::array::from_fn(|i| (g * t[i] * 2.0).clamp(0.0, 1.0) * 0.5 + g * 0.5),
|
||||
// Photoshop tints like the Color blend mode: the tint's hue and saturation at the
|
||||
// grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11.
|
||||
Some(t) => photocraft_color::blend::set_lum(*t, g).map(|v| v.clamp(0.0, 1.0)),
|
||||
None => [g; 3],
|
||||
}
|
||||
}),
|
||||
|
|
@ -236,6 +238,99 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
|
|||
}
|
||||
}
|
||||
|
||||
/// Legacy Brightness/Contrast as one channel's tone curve (see [`apply_depth`]).
|
||||
fn legacy_brightness_contrast(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 {
|
||||
let b = brightness / 255.0;
|
||||
let c = contrast.clamp(-100.0, 99.0);
|
||||
let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 };
|
||||
let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) };
|
||||
move |v: f32| ((v + pre - LEGACY_PIVOT) * k + LEGACY_PIVOT + post).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Applies an adjustment in a document of colour `mode`: [`apply_depth`], except that in Lab
|
||||
/// documents the adjustments Photoshop evaluates on the L, a and b channels do so (see
|
||||
/// [`lab_channel_adjustment`]).
|
||||
pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option<SampleType>, mode: ColorMode) {
|
||||
if mode == ColorMode::Lab && lab_channel_adjustment(adj) {
|
||||
apply_lab(adj, buf);
|
||||
} else {
|
||||
apply_depth(adj, buf, transfer, depth);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a Lab document evaluates `adj` on its L, a and b channels rather than on the screen
|
||||
/// colour: Invert and Posterize (L, a and b alike), Brightness/Contrast (L only), Threshold
|
||||
/// (split on L, neutral result) and Gradient Map (driven by L). Levels and Curves carry their own
|
||||
/// Lab records ([`ToneSpace::Lab`]); everything else stays on the screen colour.
|
||||
pub fn lab_channel_adjustment(adj: &Adjustment) -> bool {
|
||||
matches!(
|
||||
adj,
|
||||
Adjustment::Invert
|
||||
| Adjustment::Posterize { .. }
|
||||
| Adjustment::Threshold { .. }
|
||||
| Adjustment::BrightnessContrast { .. }
|
||||
| Adjustment::GradientMap { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Encoded sRGB → Lab normalised the way Photoshop's channels hold it: L/100, (a+128)/255,
|
||||
/// (b+128)/255.
|
||||
fn lab_norm(c: [f32; 3]) -> [f32; 3] {
|
||||
let l = srgb_to_lab(c);
|
||||
[l[0] / 100.0, (l[1] + 128.0) / 255.0, (l[2] + 128.0) / 255.0]
|
||||
}
|
||||
|
||||
/// The inverse of [`lab_norm`].
|
||||
fn lab_denorm(n: [f32; 3]) -> [f32; 3] {
|
||||
lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0])
|
||||
}
|
||||
|
||||
/// The Lab evaluation of a [`lab_channel_adjustment`] (no-op for any other adjustment).
|
||||
fn apply_lab(adj: &Adjustment, buf: &mut Buffer) {
|
||||
match adj {
|
||||
// n → 1 - n on every channel: L → 100 - L and a, b mirrored about neutral.
|
||||
Adjustment::Invert => map_rgb(buf, |c| lab_denorm(lab_norm(c).map(|v| 1.0 - v.clamp(0.0, 1.0)))),
|
||||
Adjustment::Posterize { levels } => map_rgb(buf, |c| lab_denorm(lab_norm(c).map(|v| posterize(v, *levels)))),
|
||||
Adjustment::Threshold { level } => {
|
||||
// Split on L alone (on the 8-bit grid); the result is neutral black or white.
|
||||
let t = (level * 255.0).round();
|
||||
map_rgb(buf, |c| [if (lab_norm(c)[0].clamp(0.0, 1.0) * 255.0).round() >= t { 1.0 } else { 0.0 }; 3])
|
||||
}
|
||||
Adjustment::BrightnessContrast { brightness, contrast, legacy } => {
|
||||
let legacy_curve = legacy_brightness_contrast(*brightness, *contrast);
|
||||
let curve = |v: f32| if *legacy { legacy_curve(v) } else { modern_contrast(modern_brightness(v, *brightness), *contrast).clamp(0.0, 1.0) };
|
||||
// The tone curve on L only. Added as the difference of two round trips (like
|
||||
// `tone_in_space`) so a and b, and colours a round trip moves, stay put.
|
||||
map_rgb(buf, |c| {
|
||||
let n = lab_norm(c);
|
||||
let l = curve(n[0]);
|
||||
if l == n[0] {
|
||||
return c;
|
||||
}
|
||||
let (before, after) = (lab_denorm(n), lab_denorm([l, n[1], n[2]]));
|
||||
std::array::from_fn(|i| (c[i] + after[i] - before[i]).clamp(0.0, 1.0))
|
||||
})
|
||||
}
|
||||
Adjustment::GradientMap { stops, reverse, dither } => {
|
||||
let (w, x0, y0) = (buf.rect.width().max(1) as usize, buf.rect.x0, buf.rect.y0);
|
||||
for (i, p) in buf.px.iter_mut().enumerate() {
|
||||
if p[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Driven by L: equal lightness maps to the same stop colour whatever the chroma.
|
||||
let l = lab_norm([p[0], p[1], p[2]])[0].clamp(0.0, 1.0);
|
||||
let mut o = gradient(stops, if *reverse { 1.0 - l } else { l });
|
||||
if *dither {
|
||||
let d = bayer4(x0 + (i % w) as i32, y0 + (i / w) as i32) / 255.0;
|
||||
o = o.map(|v| (v + d).clamp(0.0, 1.0));
|
||||
}
|
||||
p[..3].copy_from_slice(&o);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Float RGB curve channels with no edit must preserve samples outside the LUT domain.
|
||||
/// Edited curves retain the existing spline/LUT behaviour until HDR extrapolation has an oracle.
|
||||
pub fn float_tone_identity(adj: &Adjustment, depth: Option<SampleType>) -> [bool; 3] {
|
||||
|
|
@ -293,7 +388,7 @@ pub fn tone_luts_depth(adj: &Adjustment, depth: Option<SampleType>) -> [Vec<f32>
|
|||
/// through the conversions the document's surfaces use. The change is added as a difference of
|
||||
/// two round trips, so channels a curve leaves alone (and out-of-gamut colours) stay exact.
|
||||
fn tone_in_space(space: ToneSpace, luts: &[Vec<f32>; 4], c: [f32; 3]) -> [f32; 3] {
|
||||
use photocraft_color::convert::{cmyk_to_rgb, lab_to_srgb, rgb_to_cmyk, srgb_to_lab};
|
||||
use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk};
|
||||
let (before, after) = match space {
|
||||
ToneSpace::Cmyk => {
|
||||
let ink = rgb_to_cmyk(c);
|
||||
|
|
@ -336,21 +431,32 @@ pub fn hue_range_tables(ranges: &[HueRange; 6]) -> [Vec<f32>; 3] {
|
|||
/// Colorize sets the hue and saturation instead of shifting them; at 0% it leaves only the
|
||||
/// lightness, as in Photoshop (#2326). Ticking Colorize in the dialog starts saturation at 25%.
|
||||
pub fn hue_saturation(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool) -> [f32; 3] {
|
||||
let (mut hh, mut ss, ll) = rgb_to_hsl(c);
|
||||
let lighten = |v: f32| if l > 0.0 { v + (1.0 - v) * l } else { v * (1.0 + l) };
|
||||
if colorize {
|
||||
hh = hue.rem_euclid(360.0) / 360.0;
|
||||
ss = s.abs().min(1.0);
|
||||
} else {
|
||||
hh = (hh + hue / 360.0).rem_euclid(1.0);
|
||||
ss = (ss * (1.0 + s)).clamp(0.0, 1.0);
|
||||
// Colorize builds the colour at the lightened (or darkened) lightness, so black with
|
||||
// Lightness +30 becomes a 30 % lightness colour, not grey (measured in Photoshop 27.11).
|
||||
let (_, _, ll) = rgb_to_hsl(c);
|
||||
return hsl_to_rgb(hue.rem_euclid(360.0) / 360.0, s.abs().min(1.0), lighten(ll));
|
||||
}
|
||||
let mut rgb = hsl_to_rgb(hh, ss, ll);
|
||||
if l > 0.0 {
|
||||
rgb = rgb.map(|v| v + (1.0 - v) * l);
|
||||
} else if l < 0.0 {
|
||||
rgb = rgb.map(|v| v * (1.0 + l));
|
||||
}
|
||||
rgb
|
||||
// Photoshop applies Lightness first, then rotates the hue and scales the saturation of the
|
||||
// result: Hue +40, Saturation +25, Lightness -10 turns (240, 160, 30) into (173, 243, 0), not
|
||||
// (168, 230, 14) (Photoshop 27.11 corpus file adjustments/rgb8/hue-saturation).
|
||||
let (hh, ss, ll) = rgb_to_hsl(c.map(lighten));
|
||||
let hh = (hh + hue / 360.0).rem_euclid(1.0);
|
||||
// Raising saturation divides by 1 - s, lowering it multiplies by 1 + s: +50 % doubles it
|
||||
// (measured in Photoshop 27.11); +100 % saturates any colour that has some.
|
||||
let ss = if s > 0.0 { ss / (1.0 - s).max(1.0 / 512.0) } else { ss * (1.0 + s) }.clamp(0.0, 1.0);
|
||||
hsl_to_rgb(hh, ss, ll)
|
||||
}
|
||||
|
||||
/// A colour range's Lightness, applied after the master edits. It moves each channel towards the
|
||||
/// pixel's brightest channel when raised and towards its darkest when lowered, not towards white
|
||||
/// or black: Blues +50 turns (0, 29, 115) into (57, 71, 115), Blues -50 turns (102, 128, 204) into
|
||||
/// (102, 115, 153) (measured in Photoshop 27.11).
|
||||
pub fn range_lightness(c: [f32; 3], l: f32) -> [f32; 3] {
|
||||
let l = l.clamp(-1.0, 1.0);
|
||||
let target = if l > 0.0 { c[0].max(c[1]).max(c[2]) } else { c[0].min(c[1]).min(c[2]) };
|
||||
c.map(|v| v + (target - v) * l.abs())
|
||||
}
|
||||
|
||||
/// Black & White grey value. Each colour is the sum of a grey part (its smallest channel), a
|
||||
|
|
@ -502,10 +608,12 @@ pub fn selective_color_weights(c: [f32; 3]) -> [f32; 9] {
|
|||
]
|
||||
}
|
||||
|
||||
/// Photoshop Selective Color (approximation): each range shifts the cyan, magenta and yellow ink
|
||||
/// (1 − R, G, B) by its percentage, plus its black percentage on every ink, weighted by how much
|
||||
/// the colour belongs to the range. Relative mode scales the shift by the ink already present
|
||||
/// (so it can't tint pure white); absolute adds it outright.
|
||||
/// Photoshop Selective Color: each range shifts the cyan, magenta and yellow ink (1 − R, G, B) by
|
||||
/// its percentage, plus its black percentage on every ink, weighted by how much the colour belongs
|
||||
/// to the range. Relative mode scales the shift by the ink already present (so it can't tint pure
|
||||
/// white). Absolute mode scales it by the room the ink has: adding ink by min(1, 2 × (1 − ink)),
|
||||
/// removing it by min(1, 2 × ink), so Neutrals Cyan +50 darkens red 64 by 32, not 64 (all 72
|
||||
/// absolute range × ink cases within 1 level of Photoshop 27.11).
|
||||
pub fn selective_color(c: [f32; 3], relative: bool, adj: &[[f32; 4]; 9]) -> [f32; 3] {
|
||||
let w = selective_color_weights(c);
|
||||
let mut delta = [0.0f32; 3];
|
||||
|
|
@ -516,7 +624,14 @@ pub fn selective_color(c: [f32; 3], relative: bool, adj: &[[f32; 4]; 9]) -> [f32
|
|||
let a = adj[r].map(|v| v / 100.0);
|
||||
for i in 0..3 {
|
||||
let ink = 1.0 - c[i];
|
||||
let d = if relative { (a[i] + a[3]) * ink } else { a[i] + a[3] };
|
||||
let d = a[i] + a[3];
|
||||
let d = if relative {
|
||||
d * ink
|
||||
} else if d > 0.0 {
|
||||
d * (2.0 * (1.0 - ink)).min(1.0)
|
||||
} else {
|
||||
d * (2.0 * ink).min(1.0)
|
||||
};
|
||||
delta[i] += d * wr;
|
||||
}
|
||||
}
|
||||
|
|
@ -938,6 +1053,30 @@ mod lookup_tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Photoshop 27.11, measured: absolute Selective Color on greys (red channel after one range's
|
||||
/// cyan change).
|
||||
#[test]
|
||||
fn absolute_selective_color_matches_photoshop() {
|
||||
// (range index, cyan %, grey, Photoshop's red)
|
||||
let measured = [
|
||||
(7, 50.0, 64u8, 32u8),
|
||||
(7, 50.0, 192, 129),
|
||||
(7, -50.0, 192, 223),
|
||||
(7, -50.0, 64, 128),
|
||||
(6, -50.0, 192, 224),
|
||||
(6, -50.0, 240, 253),
|
||||
(8, 50.0, 64, 32),
|
||||
(8, 50.0, 16, 2),
|
||||
];
|
||||
for (range, cyan, grey, want) in measured {
|
||||
let mut adj = [[0.0f32; 4]; 9];
|
||||
adj[range][0] = cyan;
|
||||
let g = grey as f32 / 255.0;
|
||||
let got = selective_color([g; 3], false, &adj)[0] * 255.0;
|
||||
assert!((got - want as f32).abs() <= 1.5, "range {range} cyan {cyan} grey {grey}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_color_zero_is_identity_and_ranges_are_local() {
|
||||
let zero = [[0.0f32; 4]; 9];
|
||||
|
|
@ -1193,6 +1332,51 @@ mod tone_tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Photoshop 27.11, measured: Hue/Saturation raising saturation, and Colorize with Lightness.
|
||||
#[test]
|
||||
fn hue_saturation_matches_photoshop() {
|
||||
// (input, hue, saturation, lightness, colorize, Photoshop's output)
|
||||
type Case = ([u8; 3], f32, f32, f32, bool, [u8; 3]);
|
||||
let measured: [Case; 8] = [
|
||||
([204, 102, 102], 0.0, 50.0, 0.0, false, [254, 51, 51]),
|
||||
([204, 159, 102], 0.0, 50.0, 0.0, false, [254, 164, 51]),
|
||||
([204, 102, 102], 0.0, -50.0, 0.0, false, [178, 127, 127]),
|
||||
([0, 0, 0], 120.0, 100.0, 30.0, true, [0, 152, 0]),
|
||||
([28, 28, 28], 120.0, 100.0, 30.0, true, [0, 192, 0]),
|
||||
([160, 160, 160], 30.0, 25.0, -40.0, true, [120, 96, 73]),
|
||||
([252, 252, 252], 30.0, 25.0, -40.0, true, [177, 151, 126]),
|
||||
([255, 143, 0], 30.0, 25.0, -40.0, true, [95, 76, 58]),
|
||||
];
|
||||
for (input, hue, saturation, lightness, colorize, want) in measured {
|
||||
let a = Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges: HueRange::defaults() };
|
||||
let c = input.map(|v| v as f32 / 255.0);
|
||||
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[c[0], c[1], c[2], 1.0]] };
|
||||
apply(&a, &mut b);
|
||||
for (i, w) in want.iter().enumerate() {
|
||||
let got = b.px[0][i] * 255.0;
|
||||
assert!((got - *w as f32).abs() <= 2.0, "{input:?} h{hue} s{saturation} l{lightness} colorize {colorize}, channel {i}: {got} vs {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop 27.11, measured: a range's Lightness (Blues ±50) moves towards the brightest or darkest channel.
|
||||
#[test]
|
||||
fn range_lightness_matches_photoshop() {
|
||||
let measured = [
|
||||
([0u8, 29, 115], 0.5, [57u8, 71, 115]),
|
||||
([0, 64, 255], 0.5, [125, 157, 255]),
|
||||
([102, 128, 204], 0.5, [152, 165, 204]),
|
||||
([102, 128, 204], -0.5, [102, 115, 154]),
|
||||
([0, 18, 115], -0.5, [0, 9, 58]),
|
||||
];
|
||||
for (input, l, want) in measured {
|
||||
let got = range_lightness(input.map(|v| v as f32 / 255.0), l);
|
||||
for i in 0..3 {
|
||||
assert!((got[i] * 255.0 - want[i] as f32).abs() <= 2.5, "{input:?} channel {i}: {} vs {}", got[i] * 255.0, want[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_white_weights() {
|
||||
let d = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0];
|
||||
|
|
@ -1204,6 +1388,31 @@ mod tone_tests {
|
|||
assert!(black_white_gray([1.0, 0.0, 0.0], &w) > black_white_gray([1.0, 0.0, 0.0], &d));
|
||||
}
|
||||
|
||||
/// Photoshop 27.11, measured: Black & White (default weights) with Tint over greys. The tint
|
||||
/// is blended like the Color blend mode: its hue and saturation at the grey's luminosity.
|
||||
#[test]
|
||||
fn black_white_tint_matches_photoshop() {
|
||||
let weights = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0];
|
||||
let measured: [([u8; 3], u8, [u8; 3]); 6] = [
|
||||
([255, 0, 0], 24, [81, 1, 1]),
|
||||
([255, 0, 0], 120, [255, 64, 64]),
|
||||
([255, 0, 0], 240, [255, 235, 235]),
|
||||
([0xe1, 0xd3, 0xb3], 120, [133, 119, 87]),
|
||||
([0xe1, 0xd3, 0xb3], 24, [33, 24, 1]),
|
||||
([0x40, 0x20, 0x10], 120, [144, 112, 96]),
|
||||
];
|
||||
for (tint, grey, want) in measured {
|
||||
let tint = Some(tint.map(|v| v as f32 / 255.0));
|
||||
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[grey as f32 / 255.0; 4]] };
|
||||
b.px[0][3] = 1.0;
|
||||
apply(&Adjustment::BlackWhite { weights, tint }, &mut b);
|
||||
for (i, w) in want.iter().enumerate() {
|
||||
let got = b.px[0][i] * 255.0;
|
||||
assert!((got - *w as f32).abs() <= 2.5, "tint {tint:?} grey {grey} channel {i}: {got} vs {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_map_dither_stays_close() {
|
||||
let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])];
|
||||
|
|
@ -1214,6 +1423,106 @@ mod tone_tests {
|
|||
assert!(a.px.iter().zip(&b.px).all(|(x, y)| (x[0] - y[0]).abs() <= 0.5 / 255.0 + 1e-6));
|
||||
assert!(a.px.iter().zip(&b.px).any(|(x, y)| x[0] != y[0]));
|
||||
}
|
||||
|
||||
/// `adj` applied in a Lab document to one encoded sRGB colour, returned as (L, a, b).
|
||||
fn in_lab(adj: &Adjustment, c: [f32; 3]) -> [f32; 3] {
|
||||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
|
||||
apply_in(adj, &mut b, Transfer::Srgb, Some(SampleType::U8), ColorMode::Lab);
|
||||
srgb_to_lab([b.px[0][0], b.px[0][1], b.px[0][2]])
|
||||
}
|
||||
|
||||
/// A muted, in-gamut colour from (L, a, b).
|
||||
fn lab(l: f32, a: f32, b: f32) -> [f32; 3] {
|
||||
lab_to_srgb([l, a, b])
|
||||
}
|
||||
|
||||
fn near(got: [f32; 3], want: [f32; 3], tol: f32) -> bool {
|
||||
got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_invert_flips_l_a_and_b() {
|
||||
let got = in_lab(&Adjustment::Invert, lab(30.0, 12.0, -18.0));
|
||||
// n -> 1 - n: L -> 100 - L, a -> -a - 1, b -> -b - 1 (mirrored on the 0..255 grid).
|
||||
assert!(near(got, [70.0, -13.0, 17.0], 0.6), "{got:?}");
|
||||
// On the screen colour it would not be a mirror in Lab.
|
||||
let mut rgb = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 1.0]);
|
||||
let c = lab(30.0, 12.0, -18.0);
|
||||
rgb.px[0] = [c[0], c[1], c[2], 1.0];
|
||||
apply(&Adjustment::Invert, &mut rgb);
|
||||
assert!(!near(srgb_to_lab([rgb.px[0][0], rgb.px[0][1], rgb.px[0][2]]), got, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_posterize_steps_l_a_and_b() {
|
||||
let got = in_lab(&Adjustment::Posterize { levels: 2 }, lab(70.0, 20.0, -30.0));
|
||||
// Two levels: each normalised channel snaps to 0 or 1 (L 100, a and b at +127 / -128).
|
||||
assert!(near(got, srgb_to_lab(lab_denorm([1.0, 1.0, 0.0])), 1e-3), "{got:?}");
|
||||
let grey = in_lab(&Adjustment::Posterize { levels: 255 }, lab(50.0, 0.0, 0.0));
|
||||
assert!(near(grey, [50.0, 0.0, 0.0], 1.1), "255 levels is within a step of the identity: {grey:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_threshold_splits_on_l_with_a_neutral_result() {
|
||||
let t = Adjustment::Threshold { level: 0.5 };
|
||||
// A saturated blue (L ≈ 32) and yellow (L ≈ 97): black and white, nothing in between.
|
||||
for (c, white) in [([0.0, 0.0, 1.0], false), ([1.0, 1.0, 0.0], true), (lab(52.0, 40.0, 30.0), true), (lab(48.0, -40.0, 30.0), false)] {
|
||||
let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]);
|
||||
apply_in(&t, &mut b, Transfer::Srgb, None, ColorMode::Lab);
|
||||
let v = if white { 1.0 } else { 0.0 };
|
||||
assert_eq!(b.px[0], [v, v, v, 1.0], "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_brightness_contrast_moves_l_only() {
|
||||
for legacy in [false, true] {
|
||||
let adj = Adjustment::BrightnessContrast { brightness: 40.0, contrast: 30.0, legacy };
|
||||
let before = [45.0, 15.0, -20.0];
|
||||
let got = in_lab(&adj, lab(before[0], before[1], before[2]));
|
||||
assert!(got[0] > before[0] + 5.0, "{legacy}: L rises: {got:?}");
|
||||
assert!((got[1] - before[1]).abs() < 0.5 && (got[2] - before[2]).abs() < 0.5, "{legacy}: a, b kept: {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_gradient_map_follows_l() {
|
||||
let stops = vec![(0.0, [1.0, 0.0, 0.0]), (1.0, [0.0, 0.0, 1.0])];
|
||||
for reverse in [false, true] {
|
||||
let map = Adjustment::GradientMap { stops: stops.clone(), reverse, dither: false };
|
||||
// A grey and a saturated colour with the same L map to the same colour.
|
||||
let grey = in_lab(&map, lab(60.0, 0.0, 0.0));
|
||||
let green = in_lab(&map, lab(60.0, -50.0, 40.0));
|
||||
assert!(near(grey, green, 1e-3), "{reverse}: {grey:?} vs {green:?}");
|
||||
let t = if reverse { 0.4 } else { 0.6 };
|
||||
let want = srgb_to_lab([1.0 - t, 0.0, t]);
|
||||
assert!(near(grey, want, 0.05), "{reverse}: {grey:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_modes_and_adjustments_keep_the_screen_colour_path() {
|
||||
let c = [0.2, 0.5, 0.7, 1.0];
|
||||
let lab_only = [Adjustment::Invert, Adjustment::Threshold { level: 0.47 }, Adjustment::Posterize { levels: 4 }];
|
||||
for adj in
|
||||
lab_only.iter().chain([&Adjustment::HueSaturation { hue: 30.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: HueRange::defaults() }])
|
||||
{
|
||||
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale, ColorMode::Lab] {
|
||||
let (mut a, mut b) = (Buffer::filled(crate::Rect::new(0, 0, 1, 1), c), Buffer::filled(crate::Rect::new(0, 0, 1, 1), c));
|
||||
apply_in(adj, &mut a, Transfer::Srgb, None, mode);
|
||||
apply_depth(adj, &mut b, Transfer::Srgb, None);
|
||||
assert_eq!(a.px == b.px, mode != ColorMode::Lab || !lab_channel_adjustment(adj), "{adj:?} in {mode:?}");
|
||||
}
|
||||
}
|
||||
// Transparent pixels are left alone and odd values never panic.
|
||||
let mut t = Buffer::filled(crate::Rect::new(0, 0, 2, 1), [f32::NAN, 2.0, -1.0, 0.0]);
|
||||
for adj in lab_only.iter().chain([&Adjustment::GradientMap { stops: vec![], reverse: true, dither: true }]) {
|
||||
apply_in(adj, &mut t, Transfer::Srgb, None, ColorMode::Lab);
|
||||
let mut odd = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [f32::NAN, f32::INFINITY, -3.0, 1.0]);
|
||||
apply_in(adj, &mut odd, Transfer::Srgb, None, ColorMode::Lab);
|
||||
}
|
||||
assert!(t.px[0][1] == 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy Brightness/Contrast scales contrast around 128 of 255 (contrast 80 keeps 128 at 128,
|
||||
|
|
|
|||
|
|
@ -84,10 +84,10 @@ pub fn shapeless(layer: &Layer) -> bool {
|
|||
!layer.advanced.transparency_shapes && (layer.advanced.knockout != Knockout::None || effects::has_effects(layer))
|
||||
}
|
||||
|
||||
/// The Background layer of a stack: the bottom layer, a locked, opaque-by-convention pixel layer
|
||||
/// named "Background" (how PSD import and File › New create it).
|
||||
/// Whether `l`, the bottom layer of a stack, is the Background: identified by its locks and kind,
|
||||
/// never by its (localized) name ([`photocraft_doc::Document::background`]).
|
||||
pub fn is_background(l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && !l.clipped && matches!(l.content, LayerContent::Raster(_))
|
||||
l.has_background_traits()
|
||||
}
|
||||
|
||||
/// What a deep knockout reveals over `rect`: the document's initial backdrop with its Background
|
||||
|
|
|
|||
|
|
@ -437,3 +437,26 @@ fn a_knockout_background_does_not_recurse() {
|
|||
let got = render(&d, Rect::from_xywh(0, 0, 1, 1)).px[0];
|
||||
assert!(got[3] <= 1.0);
|
||||
}
|
||||
|
||||
/// The Background is found by its structure, not its English name: a file from a German or
|
||||
/// Japanese Photoshop calls it "Hintergrund" or "背景", and a deep knockout still reveals it. A
|
||||
/// bottom layer merely called "Background", without the Background's locks, is not one.
|
||||
#[test]
|
||||
fn deep_knockout_reveals_a_localized_background() {
|
||||
for depth in DEPTHS {
|
||||
for name in ["Hintergrund", "背景", "Arrière-plan"] {
|
||||
let mut d = doc(depth, true);
|
||||
d.layers[0].name = name.into();
|
||||
let green = solid(&d, "green", full(&d), GREEN);
|
||||
d.layers.push(green);
|
||||
d.layers.push(knocker(&d, Knockout::Deep));
|
||||
check(&d, 2, 2, WHITE, name);
|
||||
}
|
||||
let mut d = doc(depth, true);
|
||||
d.layers[0].locks = Default::default();
|
||||
let green = solid(&d, "green", full(&d), GREEN);
|
||||
d.layers.push(green);
|
||||
d.layers.push(knocker(&d, Knockout::Deep));
|
||||
check(&d, 2, 2, [0.0; 4], "an unlocked layer named Background");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ pub fn margin(layer: &Layer) -> i32 {
|
|||
Effect::DropShadow(s) | Effect::InnerShadow(s) => s.distance + s.size,
|
||||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.size,
|
||||
Effect::Stroke(s) => s.size,
|
||||
Effect::Satin(s) => s.distance + s.size,
|
||||
Effect::Satin(s) => satin_distance(s) + s.size,
|
||||
Effect::BevelEmboss(b) => b.size + b.soften,
|
||||
_ => 0.0,
|
||||
};
|
||||
|
|
@ -882,6 +882,16 @@ fn contour_lut(c: &Contour) -> Option<Vec<f32>> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Satin's coverage transfer: the contour read from its far end, `|c(1 − v) − c(1)|`. Measured in
|
||||
/// Photoshop 27.11: Cone and Cone − Inverted Satins are identical, and a Gaussian Satin runs under
|
||||
/// the diagonal (0.36 at 0.5) while the same Gaussian on a glow or gloss runs over it (0.65).
|
||||
/// `None` = identity.
|
||||
pub fn satin_lut(c: &Contour) -> Option<Vec<f32>> {
|
||||
let lut = contour_lut(c)?;
|
||||
let end = lut.last().copied().unwrap_or(1.0);
|
||||
Some(lut.iter().rev().map(|v| (v - end).abs()).collect())
|
||||
}
|
||||
|
||||
/// Interpolated lookup in a 0..=1 table (as the GPU's `lut()`).
|
||||
fn lut_at(l: &[f32], v: f32) -> f32 {
|
||||
let x = v.clamp(0.0, 1.0) * (l.len() - 1) as f32;
|
||||
|
|
@ -1689,8 +1699,14 @@ impl FxMaps {
|
|||
}
|
||||
}
|
||||
|
||||
/// The distance the two satin copies are offset by. Photoshop draws Distance 0 as 1 px (an
|
||||
/// edge band of 2/size, measured in Photoshop 27.11), not as two copies cancelling out.
|
||||
pub fn satin_distance(s: &photocraft_doc::effects::Satin) -> f32 {
|
||||
s.distance.max(1.0)
|
||||
}
|
||||
|
||||
fn satin_map(shape: &Map, s: &photocraft_doc::effects::Satin) -> Map {
|
||||
let (dx, dy) = offset(s.angle, s.distance);
|
||||
let (dx, dy) = offset(s.angle, satin_distance(s));
|
||||
let mut a = shape.shifted(dx, dy, 0.0);
|
||||
let mut b = shape.shifted(-dx, -dy, 0.0);
|
||||
// Two box passes of the satin size (fitted: 0.2 % mean error on psd-tools layer_effects).
|
||||
|
|
@ -1700,7 +1716,7 @@ fn satin_map(shape: &Map, s: &photocraft_doc::effects::Satin) -> Map {
|
|||
if s.invert {
|
||||
m = m.map(|v| 1.0 - v);
|
||||
}
|
||||
let mut m = apply_contour(m, &s.contour);
|
||||
let mut m = apply_lut(m, satin_lut(&s.contour));
|
||||
for (v, a) in m.v.iter_mut().zip(&shape.v) {
|
||||
*v *= a;
|
||||
}
|
||||
|
|
@ -3008,6 +3024,28 @@ mod tests {
|
|||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn satin_at_distance_zero_matches_photoshop() {
|
||||
// Photoshop 27.11, Satin black 100 %, angle 90°, Linear contour, distance 0, size 5 over a
|
||||
// #808080 square: the rows from the top edge read 82, 102, 123, 128.
|
||||
let s = photocraft_doc::effects::Satin {
|
||||
common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0),
|
||||
color: photocraft_color::Color::BLACK,
|
||||
angle: 90.0,
|
||||
distance: 0.0,
|
||||
size: 5.0,
|
||||
contour: Contour::Linear,
|
||||
anti_alias: false,
|
||||
invert: false,
|
||||
};
|
||||
let m = satin_map(&square_shape(60), &s);
|
||||
for (row, gray) in [(5, 82.0), (7, 102.0), (9, 123.0), (11, 128.0), (30, 128.0)] {
|
||||
let want = 1.0 - gray / 128.0;
|
||||
let got = m.v[row * 60 + 30];
|
||||
assert!((got - want).abs() < 0.02, "row {row}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
fn shadow(noise: f32, origin: (i32, i32)) -> Map {
|
||||
let s = Shadow {
|
||||
common: photocraft_doc::FxCommon::new(BlendMode::Multiply, 1.0),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ pub mod fill_layout;
|
|||
pub mod gradient_fill;
|
||||
pub mod masks;
|
||||
pub mod multichannel;
|
||||
pub mod native;
|
||||
pub mod pattern;
|
||||
pub mod proxy;
|
||||
pub mod psblend;
|
||||
|
|
@ -232,20 +233,48 @@ pub fn band_rows_for(width: u32, requested: i32) -> i32 {
|
|||
/// The document's composite as a pixel surface in `fmt` (straight RGBA converted to its model
|
||||
/// and depth), optionally flattened over an opaque `background`; rendered and written in bands,
|
||||
/// so no full-size float composite is held. Tiles equal to the default pixel are pruned.
|
||||
///
|
||||
/// Fully transparent pixels are written as the default pixel. Converting transparent black
|
||||
/// leaves colour behind in CMYK (K = 100%) and Lab (neutral a/b = 0.5), and those pixels would
|
||||
/// otherwise count as content: the result would span the whole canvas.
|
||||
///
|
||||
/// A CMYK document of plain Normal layers is composited in its ink values instead
|
||||
/// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778).
|
||||
pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface {
|
||||
composite_to_surface(doc, doc.bounds(), fmt, background)
|
||||
}
|
||||
|
||||
/// [`flatten_to_surface`] over `rect` only (the rest of the surface stays empty).
|
||||
pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface {
|
||||
let paper = background.is_some_and(|bg| bg == [1.0; 3]);
|
||||
if (background.is_none() || paper)
|
||||
&& let Some(s) = native::flatten_cmyk(doc, rect, fmt, paper)
|
||||
{
|
||||
return s;
|
||||
}
|
||||
let mut s = Surface::new(fmt);
|
||||
let n = fmt.channels();
|
||||
let empty = s.default_pixel();
|
||||
let mut data = Vec::new();
|
||||
let _ = render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> {
|
||||
// The composite separates back to CMYK through the document's own profile, the one its
|
||||
// pixels were read through.
|
||||
let cmyk = cmyk_space(doc);
|
||||
let _ = render_bands(doc, rect, 0, |band| -> Result<(), ()> {
|
||||
let band = match background {
|
||||
Some(bg) => band.over_background(bg),
|
||||
None => band,
|
||||
};
|
||||
data.clear();
|
||||
data.resize(band.px.len() * n, 0.0);
|
||||
for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) {
|
||||
photocraft_raster::from_rgba_into(&fmt, *p, out);
|
||||
}
|
||||
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
|
||||
for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) {
|
||||
if fmt.alpha && (p[3] <= 0.0 || p[3].is_nan()) && empty.len() == n {
|
||||
out.copy_from_slice(&empty);
|
||||
} else {
|
||||
photocraft_raster::from_rgba_into(&fmt, *p, out);
|
||||
}
|
||||
}
|
||||
});
|
||||
s.write_region(band.rect, &data);
|
||||
Ok(())
|
||||
});
|
||||
|
|
@ -294,13 +323,19 @@ pub fn thumbnail(doc: &Document, max_side: u32) -> Rgba8Image {
|
|||
thumbnail_buffer(doc, max_side).to_rgba8()
|
||||
}
|
||||
|
||||
/// Width and height of [`thumbnail`]`(doc, max_side)`.
|
||||
pub fn thumbnail_size(doc: &Document, max_side: u32) -> (u32, u32) {
|
||||
let b = doc.bounds();
|
||||
let longest = b.width().max(b.height()).max(1);
|
||||
let scale = (max_side as f32 / longest as f32).min(1.0);
|
||||
(((b.width() as f32 * scale).round() as u32).max(1), ((b.height() as f32 * scale).round() as u32).max(1))
|
||||
}
|
||||
|
||||
/// [`thumbnail`] as straight-alpha floats (for callers that colour-convert it first).
|
||||
pub fn thumbnail_buffer(doc: &Document, max_side: u32) -> Buffer {
|
||||
let b = doc.bounds();
|
||||
let longest = b.width().max(b.height()).max(1);
|
||||
let scale = (max_side as f32 / longest as f32).min(1.0);
|
||||
let w = ((b.width() as f32 * scale).round() as u32).max(1);
|
||||
let h = ((b.height() as f32 * scale).round() as u32).max(1);
|
||||
let (w, h) = thumbnail_size(doc, max_side);
|
||||
let k = longest / max_side.max(1).saturating_mul(2);
|
||||
if k >= 2 && doc.size.area() > PROXY_THUMBNAIL_PIXELS && proxy::proxy_faithful(doc) {
|
||||
render_reduced(&proxy::proxy_document(doc, k), w, h)
|
||||
|
|
@ -1248,7 +1283,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
|||
// Adjustment layers transform the backdrop, then blend the result back in.
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mut adjusted = backdrop.clone();
|
||||
adjust::apply_depth(adj, &mut adjusted, cx.transfer, Some(cx.depth));
|
||||
adjust::apply_in(adj, &mut adjusted, cx.transfer, Some(cx.depth), cx.mode);
|
||||
// Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result.
|
||||
advanced::composite_clipped(clipped, &mut adjusted, cx);
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
|
|
@ -1589,7 +1624,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
|||
let rect = base.rect;
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mut adjusted = base.clone();
|
||||
adjust::apply_depth(adj, &mut adjusted, cx.transfer, Some(cx.depth));
|
||||
adjust::apply_in(adj, &mut adjusted, cx.transfer, Some(cx.depth), cx.mode);
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
for (i, p) in base.px.iter_mut().enumerate() {
|
||||
let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i);
|
||||
|
|
@ -1674,6 +1709,18 @@ fn blend_into_fill(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity
|
|||
|
||||
/// [`blend_into`] mixing coverage in a `gamma` space (type layers).
|
||||
fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) {
|
||||
// The blend space is constant in this loop. Some TLS implementations synchronise even a
|
||||
// boolean read, so select the common Normal kernel once instead of once per pixel.
|
||||
if mode == BlendMode::Normal && gamma == 1.0 && !psblend::LAB_MIX.with(|l| l.get()) {
|
||||
for (i, b) in backdrop.px.iter_mut().enumerate() {
|
||||
let s = src.px[i];
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
*b = psblend::composite_normal_rgb(*b, s, opacity);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let rect = backdrop.rect;
|
||||
let w = rect.width() as i32;
|
||||
for (i, b) in backdrop.px.iter_mut().enumerate() {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
//! Ink density `v` (the channel value, 1 = solid) of an ink with display colour `c` and solidity
|
||||
//! `s` turns the colour underneath `p` into `(1 − s·v)·p·(1 − v + v·c) + s·v·c`: transparent inks
|
||||
//! (solidity 0) multiply like overprinted process inks, opaque ones cover what is below, which is
|
||||
//! how Photoshop previews spot channels.
|
||||
//! how Photoshop previews spot channels. An alpha-type channel of a Multichannel document
|
||||
//! (selected / masked areas) still prints, as a transparent black ink, like Photoshop shows it.
|
||||
|
||||
use photocraft_doc::Document;
|
||||
use photocraft_geom::Rect;
|
||||
|
|
@ -15,7 +16,7 @@ pub fn inks(doc: &Document, rect: Rect) -> Buffer {
|
|||
let mut buf = Buffer::filled(rect, [1.0, 1.0, 1.0, 1.0]);
|
||||
let mut vals = Vec::new();
|
||||
for ch in &doc.channels {
|
||||
let Some((ink, solidity)) = ch.spot else { continue };
|
||||
let (ink, solidity) = ch.spot.unwrap_or((photocraft_color::Color::BLACK, 0.0));
|
||||
if ch.surface.content_bounds().intersect(&rect).is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -65,7 +66,15 @@ mod tests {
|
|||
assert_eq!(r.px[1], [0.0, 1.0, 1.0, 1.0]);
|
||||
assert_eq!(r.px[2], [0.0, 0.0, 1.0, 1.0]);
|
||||
assert_eq!(r.px[3], [1.0, 0.0, 1.0, 1.0]);
|
||||
// Alpha channels and other modes are not inks.
|
||||
// An alpha-type channel prints as a black ink.
|
||||
let mut k = Surface::new(fmt);
|
||||
k.write_region(Rect::new(0, 0, 4, 1), &[0.5, 0.0, 0.0, 0.0]);
|
||||
doc.channels.push(AlphaChannel::new("Alpha", k));
|
||||
let r = crate::flatten(&doc);
|
||||
// 0.5 is stored as 128/255 in an 8-bit channel: within one 8-bit step.
|
||||
assert!((r.px[0][0] - 0.5).abs() <= 1.0 / 255.0 && (r.px[0][1] - 0.5).abs() <= 1.0 / 255.0, "{:?}", r.px[0]);
|
||||
assert_eq!(r.px[1], [0.0, 1.0, 1.0, 1.0]);
|
||||
// Other modes have no inks.
|
||||
doc.mode = ColorMode::Rgb;
|
||||
assert_eq!(crate::flatten(&doc).px[1][3], 0.0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
//! Mode-native flattening of plain CMYK layer stacks (#3778).
|
||||
//!
|
||||
//! The compositor works in display RGB, so flattening a CMYK document through it reads every ink
|
||||
//! value through the profile and separates it again: even a lone Background comes back with
|
||||
//! different ink. When every visible layer is a plain raster layer in Normal mode (no masks,
|
||||
//! effects, clipping or advanced blending), the composite needs no colour conversion at all:
|
||||
//! Normal mixes ink values linearly, as Photoshop does in CMYK, so it is worked out on the stored
|
||||
//! values and an opaque layer keeps them exactly.
|
||||
|
||||
use photocraft_color::blend::BlendMode;
|
||||
use photocraft_color::{ColorMode, PixelFormat};
|
||||
use photocraft_doc::{Document, Layer, LayerContent};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// Process channels of a CMYK pixel.
|
||||
const INKS: usize = 4;
|
||||
|
||||
/// A layer the native path can composite: a visible raster layer the compositor would only blend
|
||||
/// Normally at its opacity.
|
||||
fn plain(layer: &Layer) -> Option<&Surface> {
|
||||
let LayerContent::Raster(s) = &layer.content else { return None };
|
||||
let simple = layer.blend == BlendMode::Normal
|
||||
&& !layer.clipped
|
||||
&& layer.mask.as_ref().is_none_or(|m| !m.enabled)
|
||||
&& layer.vector_mask.as_ref().is_none_or(|v| !v.enabled)
|
||||
&& !crate::effects::has_effects(layer)
|
||||
&& layer.excluded_channels == 0
|
||||
&& layer.blend_if.is_default()
|
||||
&& layer.advanced.is_default()
|
||||
&& layer.video.is_none()
|
||||
&& s.format().mode == ColorMode::Cmyk;
|
||||
simple.then_some(s)
|
||||
}
|
||||
|
||||
/// The composite of `doc` over `rect` in `fmt`, worked out in ink values, or `None` when the
|
||||
/// document isn't CMYK or a visible layer needs the full compositor (see the module docs).
|
||||
/// `paper` flattens onto white paper (no ink) and makes the result opaque.
|
||||
pub fn flatten_cmyk(doc: &Document, rect: Rect, fmt: PixelFormat, paper: bool) -> Option<Surface> {
|
||||
if doc.mode != ColorMode::Cmyk || fmt.mode != ColorMode::Cmyk || doc.layers.iter().any(|l| l.clipped) {
|
||||
return None;
|
||||
}
|
||||
let mut stack = Vec::new();
|
||||
for layer in doc.layers.iter().filter(|l| l.visible) {
|
||||
let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0);
|
||||
stack.push((plain(layer)?, alpha));
|
||||
}
|
||||
let mut out = Surface::new(fmt);
|
||||
if rect.is_empty() {
|
||||
return Some(out);
|
||||
}
|
||||
let n = fmt.channels();
|
||||
let w = rect.width() as usize;
|
||||
let rows = crate::band_rows_for(rect.width(), 0);
|
||||
let (mut acc, mut src, mut data) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let mut y = rect.y0;
|
||||
while y < rect.y1 {
|
||||
let band = Rect::new(rect.x0, y, rect.x1, y.saturating_add(rows).min(rect.y1));
|
||||
// Straight C, M, Y, K, alpha per pixel, starting transparent.
|
||||
acc.clear();
|
||||
acc.resize(w * band.height() as usize, [0.0f32; INKS + 1]);
|
||||
for &(surf, opacity) in &stack {
|
||||
if opacity <= 0.0 || (surf.content_bounds().intersect(&band).is_empty() && surf.default_pixel().iter().all(|v| *v == 0.0)) {
|
||||
continue;
|
||||
}
|
||||
let sf = surf.format();
|
||||
let sn = sf.channels();
|
||||
surf.read_region_into(band, &mut src);
|
||||
for (d, s) in acc.iter_mut().zip(src.chunks_exact(sn)) {
|
||||
let sa = if sf.alpha { s[INKS] } else { 1.0 } * opacity;
|
||||
if sa <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let oa = sa + d[INKS] * (1.0 - sa);
|
||||
let keep = d[INKS] * (1.0 - sa);
|
||||
for c in 0..INKS {
|
||||
// An opaque pixel replaces what is below exactly (no rounding through the mix).
|
||||
d[c] = if sa >= 1.0 { s[c] } else { (s[c] * sa + d[c] * keep) / oa };
|
||||
}
|
||||
d[INKS] = oa;
|
||||
}
|
||||
}
|
||||
data.clear();
|
||||
data.resize(acc.len() * n, 0.0);
|
||||
for (p, o) in acc.iter().zip(data.chunks_exact_mut(n)) {
|
||||
// White paper carries no ink, so flattening onto it scales the ink by coverage.
|
||||
let a = p[INKS];
|
||||
let k = if paper && a < 1.0 { a } else { 1.0 };
|
||||
for c in 0..INKS {
|
||||
o[c] = p[c] * k;
|
||||
}
|
||||
if fmt.alpha {
|
||||
o[INKS] = if paper { 1.0 } else { a };
|
||||
}
|
||||
}
|
||||
out.write_region(band, &data);
|
||||
y = band.y1;
|
||||
}
|
||||
out.prune();
|
||||
Some(out)
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ impl<'a> PreparedPatterns<'a> {
|
|||
|
||||
pub(crate) fn get(&self, id: &str, name: &str) -> Option<Arc<Tile>> {
|
||||
let p = photocraft_doc::pattern::find(self.patterns, id, name)?;
|
||||
if p.is_empty() {
|
||||
if !p.is_paintable() {
|
||||
return None;
|
||||
}
|
||||
let bytes = usize::try_from(p.width)
|
||||
|
|
@ -87,9 +87,13 @@ impl<'a> PreparedPatterns<'a> {
|
|||
/// colour mode `mode`. A pattern keeps its own colours (an RGB pattern stays RGB, in the
|
||||
/// library and in the document), but in a Grayscale or CMYK document it paints that mode's
|
||||
/// colours: what Edit › Fill writes into the document's pixels, and what Photoshop shows (the
|
||||
/// pattern in colour in its picker, filled in grey).
|
||||
/// pattern in colour in its picker, filled in grey). Empty for a pattern that isn't
|
||||
/// [paintable](Pattern::is_paintable).
|
||||
pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> {
|
||||
let mut px = vec![[0.0f32; 4]; (p.width as usize).saturating_mul(p.height as usize)];
|
||||
let Some(n) = p.is_paintable().then(|| usize::try_from(p.pixel_count()).ok()).flatten() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut px = vec![[0.0f32; 4]; n];
|
||||
p.surface.read_rgba_into(p.rect(), &mut px);
|
||||
let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk);
|
||||
if paints_in && p.surface.format().mode != mode {
|
||||
|
|
@ -117,8 +121,9 @@ impl Tile {
|
|||
}
|
||||
|
||||
/// The tile of `p` painted into a document of colour mode `mode` (see [`pattern_rgba`]).
|
||||
/// `None` when the pattern isn't [paintable](Pattern::is_paintable).
|
||||
pub fn new_in(p: &Pattern, mode: ColorMode) -> Option<Tile> {
|
||||
if p.is_empty() {
|
||||
if !p.is_paintable() {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (p.width as usize, p.height as usize);
|
||||
|
|
@ -255,6 +260,17 @@ mod tests {
|
|||
assert_eq!(px[8], [0.0, 0.0, 1.0, 1.0]); // x = 4
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patterns_too_large_to_paint_are_skipped_without_allocating() {
|
||||
// Only the size is declared (no tiles): a hostile file can claim any size.
|
||||
let p = Pattern { id: "big".into(), name: "Big".into(), width: 9000, height: 8000, surface: Surface::new(PixelFormat::RGBA8) };
|
||||
assert!(!p.is_paintable());
|
||||
assert!(Tile::new(&p).is_none());
|
||||
assert!(pattern_rgba(&p, ColorMode::Rgb).is_empty());
|
||||
let list = [p];
|
||||
assert!(PreparedPatterns::new(&list, 0).get("big", "").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_phase_scale_and_angle_move_the_origin() {
|
||||
let t = Tile::new(&checker()).unwrap();
|
||||
|
|
|
|||
|
|
@ -83,15 +83,52 @@ pub fn proxy_document(doc: &Document, k: u32) -> Document {
|
|||
}
|
||||
|
||||
/// Whether a proxy composite is a faithful reduction of `doc`: layer effects (whose sizes are in
|
||||
/// document pixels) and vector masks (paths in document coordinates) would not scale with it.
|
||||
/// document pixels) and nonempty vector masks (paths in document coordinates) would not scale
|
||||
/// with it. Empty vector masks have constant coverage, including inversion and density.
|
||||
/// Nonempty paths can still anchor fill layouts even when their mask density is zero.
|
||||
pub fn proxy_faithful(doc: &Document) -> bool {
|
||||
doc.walk().iter().all(|(_, _, l)| !crate::effects::has_effects(l) && l.vector_mask.as_ref().is_none_or(|v| !v.enabled))
|
||||
doc.walk().iter().all(|(_, _, l)| !crate::effects::has_effects(l) && l.vector_mask.as_ref().is_none_or(|v| !v.enabled || v.path.is_empty()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::vector::{Knot, Path, Subpath, VectorMask};
|
||||
use photocraft_doc::{Effect, Fill, GradientStyle, LayerMask};
|
||||
|
||||
fn two_colors(size: Size, depth: SampleType) -> Document {
|
||||
let mut doc = Document::new("mask", size, ColorMode::Rgb, depth);
|
||||
let format = doc.pixel_format();
|
||||
for (name, color) in [("blue", [0.0, 0.0, 1.0, 1.0]), ("red", [1.0, 0.0, 0.0, 1.0])] {
|
||||
doc.layers.push(Layer::new(name, LayerContent::Raster(Surface::with_default(format, &color))));
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
fn assert_same_pixels(actual: &crate::Buffer, expected: &crate::Buffer) {
|
||||
assert_same_pixels_within(actual, expected, 1e-6);
|
||||
}
|
||||
|
||||
fn assert_same_pixels_within(actual: &crate::Buffer, expected: &crate::Buffer, tolerance: f32) {
|
||||
assert_eq!(actual.rect, expected.rect);
|
||||
assert_eq!(actual.px.len(), expected.px.len());
|
||||
for (a, b) in actual.px.iter().zip(&expected.px) {
|
||||
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() <= tolerance), "{a:?} != {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn u16_coverage(value: f32) -> f32 {
|
||||
// Feathered masks use the existing U16 combined-mask surface, including its default.
|
||||
(value.clamp(0.0, 1.0) * 65535.0 + 0.5).floor() / 65535.0
|
||||
}
|
||||
|
||||
fn opacity_reference(doc: &Document, coverage: f32) -> Document {
|
||||
let mut reference = doc.clone();
|
||||
reference.layers[1].vector_mask = None;
|
||||
reference.layers[1].opacity *= coverage;
|
||||
reference
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downsample_picks_every_kth_pixel() {
|
||||
|
|
@ -134,4 +171,130 @@ mod tests {
|
|||
assert_eq!((s.pixel(7, 15)[0], s.pixel(8, 0)[0]), (1.0, 0.0));
|
||||
assert!(proxy_document(&Document::new("n", Size::new(8, 8), ColorMode::Rgb, SampleType::U8), 4).selection.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_accepts_empty_paths_but_keeps_geometry_and_effect_guards() {
|
||||
let mut doc = two_colors(Size::new(32, 16), SampleType::F32);
|
||||
doc.layers[1].vector_mask = Some(VectorMask::new(Path::new(vec![Subpath::default()])));
|
||||
assert!(proxy_faithful(&doc));
|
||||
// A lone knot has no fillable segments, but still carries document coordinates.
|
||||
doc.layers[1].vector_mask.as_mut().unwrap().path.subpaths[0].knots.push(Knot::corner(12.0, 8.0));
|
||||
assert!(!proxy_faithful(&doc));
|
||||
doc.layers[1].vector_mask.as_mut().unwrap().enabled = false;
|
||||
assert!(proxy_faithful(&doc));
|
||||
doc.layers[1].vector_mask = Some(VectorMask::new(Path::default()));
|
||||
doc.layers[1].effects.items.push(Effect::default_drop_shadow());
|
||||
doc.layers[1].effects.enabled = true;
|
||||
assert!(!proxy_faithful(&doc));
|
||||
doc.layers[1].effects.items[0].set_enabled(false);
|
||||
assert!(proxy_faithful(&doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_vector_masks_keep_constant_coverage_at_every_depth() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut doc = two_colors(Size::new(32, 16), depth);
|
||||
for path in [Path::default(), Path::new(vec![Subpath::default()])] {
|
||||
for inverted in [false, true] {
|
||||
for density in [0.0, 0.25, 1.0] {
|
||||
for feather in [0.0, 4.0] {
|
||||
let mut mask = VectorMask::new(path.clone());
|
||||
mask.path.inverted = inverted;
|
||||
mask.density = density;
|
||||
mask.feather = feather;
|
||||
doc.layers[1].vector_mask = Some(mask);
|
||||
assert!(proxy_faithful(&doc));
|
||||
let coverage = if inverted { 1.0 - density } else { 1.0 };
|
||||
let coverage = if feather > 0.0 { u16_coverage(coverage) } else { coverage };
|
||||
let expected = [coverage, 0.0, 1.0 - coverage, 1.0];
|
||||
let full = crate::render(&doc, doc.bounds());
|
||||
for pixel in &full.px {
|
||||
assert!(pixel.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6), "{depth:?} {inverted} {density} {feather}: {pixel:?}");
|
||||
}
|
||||
let proxy = proxy_document(&doc, 4);
|
||||
assert_eq!(proxy.layers[1].vector_mask, doc.layers[1].vector_mask);
|
||||
let reference = proxy_document(&opacity_reference(&doc, coverage), 4);
|
||||
assert_same_pixels(&crate::render(&proxy, proxy.bounds()), &crate::render(&reference, reference.bounds()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_vector_mask_multiplies_a_feathered_pixel_mask_on_the_proxy() {
|
||||
let mut doc = two_colors(Size::new(32, 16), SampleType::F32);
|
||||
let mut surface = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), &[0.25]);
|
||||
surface.fill_rect(Rect::new(0, 0, 16, 16), &[0.75]);
|
||||
doc.layers[1].mask = Some(LayerMask { surface, density: 0.5, feather: 2.0, ..LayerMask::reveal_all() });
|
||||
let mut mask = VectorMask::new(Path { inverted: true, ..Path::default() });
|
||||
mask.density = 0.75;
|
||||
mask.feather = 8.0;
|
||||
doc.layers[1].vector_mask = Some(mask);
|
||||
assert!(proxy_faithful(&doc));
|
||||
let reference = opacity_reference(&doc, 0.25);
|
||||
for k in [1, 2, 4] {
|
||||
let (actual, expected) = (proxy_document(&doc, k), proxy_document(&reference, k));
|
||||
// Q(0.25 * pixel) versus 0.25 * Q(pixel): U16 rounding differs by at most
|
||||
// 0.625 / 65535. Allow one U16 level plus a small float arithmetic allowance.
|
||||
assert_same_pixels_within(&crate::render(&actual, actual.bounds()), &crate::render(&expected, expected.bounds()), 1.0 / 65535.0 + 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_vector_masks_keep_the_pixel_mask_gradient_frame() {
|
||||
let mut doc = two_colors(Size::new(32, 16), SampleType::F32);
|
||||
doc.layers[1].content = LayerContent::Fill(Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, GradientStyle::Linear, false));
|
||||
let mut pixel = LayerMask::hide_all();
|
||||
pixel.surface.fill_rect(Rect::new(8, 4, 24, 12), &[1.0]);
|
||||
doc.layers[1].mask = Some(pixel);
|
||||
for inverted in [false, true] {
|
||||
let mut mask = VectorMask::new(Path { inverted, ..Path::default() });
|
||||
mask.density = 0.5;
|
||||
mask.feather = 3.0;
|
||||
doc.layers[1].vector_mask = Some(mask);
|
||||
assert!(proxy_faithful(&doc));
|
||||
assert_eq!(crate::fill_frame(&doc.layers[1], doc.bounds()), Rect::new(8, 4, 24, 12));
|
||||
let actual = proxy_document(&doc, 4);
|
||||
assert_eq!(crate::fill_frame(&actual.layers[1], actual.bounds()), Rect::new(2, 1, 6, 3));
|
||||
let reference = proxy_document(&opacity_reference(&doc, u16_coverage(if inverted { 0.5 } else { 1.0 })), 4);
|
||||
assert_same_pixels(&crate::render(&actual, actual.bounds()), &crate::render(&reference, reference.bounds()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_density_geometry_still_blocks_the_proxy_and_anchors_a_gradient() {
|
||||
let mut doc = Document::new("gradient", Size::new(64, 32), ColorMode::Rgb, SampleType::F32);
|
||||
let fill = Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, GradientStyle::Linear, false);
|
||||
let mut layer = Layer::new("gradient", LayerContent::Fill(fill));
|
||||
let path = Path::new(vec![Subpath::polygon(&[(16.0, 8.0), (48.0, 8.0), (48.0, 24.0), (16.0, 24.0)])]);
|
||||
let mut mask = VectorMask::new(path);
|
||||
mask.density = 0.0;
|
||||
layer.vector_mask = Some(mask);
|
||||
doc.layers.push(layer);
|
||||
assert!(!proxy_faithful(&doc));
|
||||
assert_eq!(crate::fill_frame(&doc.layers[0], doc.bounds()), Rect::new(16, 8, 48, 24));
|
||||
// Density zero reveals all pixels, but the unscaled path would still move the fill.
|
||||
let forced_proxy = proxy_document(&doc, 4);
|
||||
let exact = crate::render_reduced(&doc, 16, 8);
|
||||
let wrong = crate::render(&forced_proxy, forced_proxy.bounds());
|
||||
assert!(exact.px.iter().zip(&wrong.px).any(|(a, b)| (a[0] - b[0]).abs() > 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_thumbnail_keeps_inverted_empty_mask_coverage() {
|
||||
// Default-only surfaces exercise the large-document branch without allocating 17 MP.
|
||||
let mut doc = two_colors(Size::new(4200, 4200), SampleType::F32);
|
||||
let mut mask = VectorMask::new(Path { inverted: true, ..Path::default() });
|
||||
mask.density = 0.75;
|
||||
mask.feather = 8.0;
|
||||
doc.layers[1].vector_mask = Some(mask);
|
||||
assert!(doc.size.area() > crate::PROXY_THUMBNAIL_PIXELS);
|
||||
assert!(proxy_faithful(&doc));
|
||||
let actual = crate::thumbnail_buffer(&doc, 4);
|
||||
assert_eq!(actual.rect, Rect::new(0, 0, 4, 4));
|
||||
let coverage = u16_coverage(0.25);
|
||||
assert_eq!(actual.px, vec![[coverage, 0.0, 1.0 - coverage, 1.0]; 16]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,13 +210,7 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity:
|
|||
return [r[0], r[1], r[2], ao];
|
||||
}
|
||||
if mode == BlendMode::Normal {
|
||||
// Fast path: B(Cb, Cs) = Cs.
|
||||
if ao <= 0.0 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let kb = ab * (1.0 - as_);
|
||||
let inv = 1.0 / ao;
|
||||
return [(kb * backdrop[0] + as_ * source[0]) * inv, (kb * backdrop[1] + as_ * source[1]) * inv, (kb * backdrop[2] + as_ * source[2]) * inv, ao];
|
||||
return normal_rgb(backdrop, source, as_, ao);
|
||||
}
|
||||
let cb = [backdrop[0], backdrop[1], backdrop[2]];
|
||||
let cs = [source[0], source[1], source[2]];
|
||||
|
|
@ -232,6 +226,29 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity:
|
|||
out
|
||||
}
|
||||
|
||||
/// Normal mixing outside Lab, for callers that select the blend space once per buffer.
|
||||
#[inline]
|
||||
pub(crate) fn composite_normal_rgb(backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] {
|
||||
let ab = backdrop[3];
|
||||
let as_ = source[3] * opacity;
|
||||
if as_ <= 0.0 {
|
||||
return backdrop;
|
||||
}
|
||||
let ao = as_ + ab * (1.0 - as_);
|
||||
normal_rgb(backdrop, source, as_, ao)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn normal_rgb(backdrop: [f32; 4], source: [f32; 4], as_: f32, ao: f32) -> [f32; 4] {
|
||||
// B(Cb, Cs) = Cs. Keep the scalar composite's operation order and straight alpha.
|
||||
if ao <= 0.0 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let kb = backdrop[3] * (1.0 - as_);
|
||||
let inv = 1.0 / ao;
|
||||
[(kb * backdrop[0] + as_ * source[0]) * inv, (kb * backdrop[1] + as_ * source[1]) * inv, (kb * backdrop[2] + as_ * source[2]) * inv, ao]
|
||||
}
|
||||
|
||||
/// Photoshop's "Blend Text Colors Using Gamma" (Color Settings › Advanced, on at 1.45 by
|
||||
/// default): type layers mix their colour with the backdrop in linear light raised to 1 / 1.45
|
||||
/// ([`text_encode`]), so anti-aliased glyph edges come out lighter over dark backdrops than a
|
||||
|
|
|
|||
|
|
@ -40,6 +40,147 @@ fn normal_layer_over_background() {
|
|||
assert!(close4(px(&d, 5, 1), [1.0; 4]));
|
||||
}
|
||||
|
||||
// The pre-optimisation Normal formula, independent of the production helper. Its operation
|
||||
// order matters: an algebraically equivalent premultiplied mix can round differently.
|
||||
fn original_normal_rgb(backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] {
|
||||
let ab = backdrop[3];
|
||||
let as_ = source[3] * opacity;
|
||||
if as_ <= 0.0 {
|
||||
return backdrop;
|
||||
}
|
||||
let ao = as_ + ab * (1.0 - as_);
|
||||
if ao <= 0.0 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let kb = ab * (1.0 - as_);
|
||||
let inv = 1.0 / ao;
|
||||
[(kb * backdrop[0] + as_ * source[0]) * inv, (kb * backdrop[1] + as_ * source[1]) * inv, (kb * backdrop[2] + as_ * source[2]) * inv, ao]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_rgb_kernel_preserves_scalar_bits() {
|
||||
let subnormal = f32::from_bits(1);
|
||||
let backdrops = [[0.0, -0.0, 2.0, 0.0], [0.2, 0.4, 0.6, 0.5], [2.0, -0.25, 4.0, 1.0], [-1.0, 0.01, 1.0, -0.5], [1.0, -0.0, f32::MIN_POSITIVE, subnormal]];
|
||||
let sources = [
|
||||
[0.9, 0.1, 0.3, 1.0],
|
||||
[0.25, 2.0, -1.0, 0.5],
|
||||
[subnormal, 1e-20, 1.0, subnormal],
|
||||
[0.5, 0.2, 0.7, -0.0],
|
||||
[0.5, 0.2, 0.7, -0.5],
|
||||
[f32::NAN, 0.2, 0.7, 0.0],
|
||||
];
|
||||
for b in backdrops {
|
||||
for s in sources {
|
||||
for opacity in [-1.0, -0.0, 0.0, 0.5, 1.0, 2.0] {
|
||||
let want = original_normal_rgb(b, s, opacity);
|
||||
let got = psblend::composite_normal_rgb(b, s, opacity);
|
||||
assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{b:?} / {s:?} / {opacity}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normal_rgb_test_buffers(fmt: PixelFormat) -> (Buffer, Buffer) {
|
||||
let rect = Rect::new(-3, -2, 5, 0);
|
||||
let mut bottom = Surface::new(fmt);
|
||||
let mut top = Surface::new(fmt);
|
||||
for y in rect.y0..rect.y1 {
|
||||
for x in rect.x0..rect.x1 {
|
||||
let i = ((y - rect.y0) * rect.width() as i32 + x - rect.x0) as usize;
|
||||
let t = i as f32 / 16.0;
|
||||
let b = [2.0 * t, 0.4, -0.25 + t, [0.0, 0.25, 0.75, 1.0][i % 4]];
|
||||
let s = [0.9 - t, 0.1 + t, 0.3, [0.0, -0.0, 0.5, 1.0][i % 4]];
|
||||
bottom.write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, b));
|
||||
top.write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, s));
|
||||
}
|
||||
}
|
||||
(surface_to_buffer(&bottom, rect), surface_to_buffer(&top, rect))
|
||||
}
|
||||
|
||||
// Keep the old buffer dispatch as an oracle for the specialised loop and all of its fallbacks.
|
||||
fn original_blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) {
|
||||
let rect = backdrop.rect;
|
||||
let w = rect.width() as i32;
|
||||
for (i, b) in backdrop.px.iter_mut().enumerate() {
|
||||
let mut s = src.px[i];
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut mode = mode;
|
||||
if mode == BlendMode::Dissolve {
|
||||
let x = rect.x0 + (i as i32 % w);
|
||||
let y = rect.y0 + (i as i32 / w);
|
||||
s[3] = if dissolve_noise(x, y) < s[3] * opacity { 1.0 } else { 0.0 };
|
||||
mode = BlendMode::Normal;
|
||||
*b = blend::composite(mode, *b, s, 1.0);
|
||||
continue;
|
||||
}
|
||||
*b = blend::composite_gamma(mode, *b, s, opacity, gamma);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_buffer_bits(got: &Buffer, want: &Buffer) {
|
||||
assert_eq!(got.rect, want.rect);
|
||||
assert_eq!(got.px.len(), want.px.len());
|
||||
for (i, (got, want)) in got.px.iter().zip(&want.px).enumerate() {
|
||||
assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "pixel {i}: {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_rgb_buffer_dispatch_and_fallbacks_preserve_bits() {
|
||||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||||
let (bottom, top) = normal_rgb_test_buffers(fmt);
|
||||
for lab in [false, true] {
|
||||
psblend::with_lab_mix(lab, || {
|
||||
psblend::with_hdr(fmt.sample == SampleType::F32, || {
|
||||
for mode in BlendMode::LAYER_MODES {
|
||||
for gamma in [1.0, psblend::TEXT_GAMMA] {
|
||||
for opacity in [0.0, 0.5, 1.0] {
|
||||
let mut got = bottom.clone();
|
||||
let mut want = bottom.clone();
|
||||
original_blend_into_g(&mut want, &top, mode, opacity, gamma);
|
||||
blend_into_g(&mut got, &top, mode, opacity, gamma);
|
||||
assert_buffer_bits(&got, &want);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_rgb_buffer_preserves_alpha_skip_and_nested_scopes() {
|
||||
let rect = Rect::new(-1, -1, 2, 0);
|
||||
let bottom = Buffer::filled(rect, [-0.0, 0.4, 2.0, 0.75]);
|
||||
let top = Buffer { rect, px: vec![[f32::NAN, 0.3, 0.1, -0.5], [0.2, 0.3, 0.1, -0.0], [0.2, 0.3, 0.1, f32::NAN]] };
|
||||
psblend::with_lab_mix(true, || {
|
||||
psblend::with_hdr(true, || {
|
||||
psblend::with_lab_mix(false, || {
|
||||
psblend::with_hdr(false, || {
|
||||
let mut got = bottom.clone();
|
||||
let mut want = bottom.clone();
|
||||
original_blend_into_g(&mut want, &top, BlendMode::Normal, -1.0, 1.0);
|
||||
blend_into_g(&mut got, &top, BlendMode::Normal, -1.0, 1.0);
|
||||
assert_buffer_bits(&got, &want);
|
||||
assert_eq!(got.px[0].map(f32::to_bits), bottom.px[0].map(f32::to_bits));
|
||||
assert_eq!(got.px[1].map(f32::to_bits), bottom.px[1].map(f32::to_bits));
|
||||
});
|
||||
assert!(psblend::HDR.with(|h| h.get()));
|
||||
});
|
||||
assert!(psblend::LAB_MIX.with(|l| l.get()));
|
||||
assert!(psblend::HDR.with(|h| h.get()));
|
||||
let mut got = bottom.clone();
|
||||
let mut want = bottom.clone();
|
||||
original_blend_into_g(&mut want, &top, BlendMode::Normal, 0.5, 1.0);
|
||||
blend_into_g(&mut got, &top, BlendMode::Normal, 0.5, 1.0);
|
||||
assert_buffer_bits(&got, &want);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_and_fill_multiply() {
|
||||
let mut d = doc_white(4, 4);
|
||||
|
|
@ -463,6 +604,34 @@ fn cmyk_document_renders_via_rgb() {
|
|||
assert!(p[0] < 0.3 && (p[0] - p[1]).abs() < 0.05 && (p[1] - p[2]).abs() < 0.05 && p[3] == 1.0, "{p:?}");
|
||||
}
|
||||
|
||||
/// Transparent pixels convert to colour in CMYK (K = 100%) and Lab (a/b = 0.5). A flattened
|
||||
/// surface must still treat them as empty, or its content spans the whole canvas.
|
||||
#[test]
|
||||
fn flatten_to_surface_leaves_transparent_pixels_empty_in_every_mode() {
|
||||
let modes = [
|
||||
(ColorMode::Rgb, [SampleType::U8, SampleType::U16, SampleType::F32].as_slice()),
|
||||
(ColorMode::Grayscale, &[SampleType::U8, SampleType::U16, SampleType::F32]),
|
||||
(ColorMode::Cmyk, &[SampleType::U8, SampleType::U16]),
|
||||
(ColorMode::Lab, &[SampleType::U8, SampleType::U16]),
|
||||
];
|
||||
for (mode, samples) in modes {
|
||||
for &sample in samples {
|
||||
let mut d = Document::new("t", Size::new(300, 200), mode, sample);
|
||||
let fmt = PixelFormat::new(mode, sample, true);
|
||||
let mut l = Layer::raster("dot", fmt);
|
||||
let opaque: Vec<f32> = photocraft_raster::from_rgba(&fmt, [0.9, 0.4, 0.1, 1.0]);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(120, 80, 140, 90), &opaque);
|
||||
d.layers.push(l);
|
||||
let s = flatten_to_surface(&d, fmt, None);
|
||||
assert_eq!(s.content_bounds(), Rect::new(120, 80, 140, 90), "{mode:?} {sample:?}");
|
||||
// Over a background everything is content, as before.
|
||||
let fmt_opaque = PixelFormat::new(mode, sample, false);
|
||||
let s = flatten_to_surface(&d, fmt_opaque, Some([1.0, 1.0, 1.0]));
|
||||
assert_eq!(s.rgba(5, 5)[3], 1.0, "{mode:?} {sample:?}: background stays opaque");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_and_float_layers_composite() {
|
||||
for fmt in [PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||||
|
|
@ -2203,3 +2372,118 @@ fn outer_glow_does_not_paint_through_a_zero_fill_layer() {
|
|||
assert!(opaque[0] > 0.9 && opaque[1] > 0.8, "{technique:?}: opaque fill disappeared: {opaque:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Lab documents: Invert mirrors L, a and b; Threshold splits on L with a neutral result; Gradient
|
||||
// Map follows L. Rendered through the document compositor at every depth.
|
||||
#[test]
|
||||
fn lab_documents_evaluate_invert_threshold_and_gradient_map_on_lab() {
|
||||
use photocraft_color::convert::{lab_to_srgb, srgb_to_lab};
|
||||
let c = lab_to_srgb([30.0, 12.0, -18.0]);
|
||||
for depth in SampleType::ALL {
|
||||
let render = |adj: Adjustment| {
|
||||
let mut d = Document::with_background("t", Size::new(4, 4), ColorMode::Lab, depth, Color::rgb(c[0], c[1], c[2]));
|
||||
d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj)));
|
||||
let p = flatten(&d).px[5];
|
||||
srgb_to_lab([p[0], p[1], p[2]])
|
||||
};
|
||||
let near = |got: [f32; 3], want: [f32; 3], tol: f32| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol);
|
||||
let inv = render(Adjustment::Invert);
|
||||
assert!(near(inv, [70.0, -13.0, 17.0], 1.0), "{depth:?}: {inv:?}");
|
||||
let th = render(Adjustment::Threshold { level: 0.25 });
|
||||
assert!(near(th, [100.0, 0.0, 0.0], 0.5), "{depth:?}: L 30 is above level 64: {th:?}");
|
||||
let gm = render(Adjustment::GradientMap { stops: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], reverse: false, dither: false });
|
||||
let want = srgb_to_lab([0.3; 3]);
|
||||
assert!(near(gm, want, 1.0), "{depth:?}: L 30 → 30 % of the way: {gm:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `adj` over a one-colour Lab document (background `rgb`) at `depth`, read back as Lab.
|
||||
fn lab_doc_adjusted(rgb: [f32; 3], adj: Adjustment, depth: SampleType) -> [f32; 3] {
|
||||
let mut doc = Document::with_background("lab", Size::new(4, 4), ColorMode::Lab, depth, Color::rgb(rgb[0], rgb[1], rgb[2]));
|
||||
doc.layers.push(Layer::new("adj", LayerContent::Adjustment(adj)));
|
||||
let p = px(&doc, 1, 1);
|
||||
photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]])
|
||||
}
|
||||
|
||||
fn near3(got: [f32; 3], want: [f32; 3], tol: f32, what: &str) {
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol), "{what}: got {got:?}, want {want:?}");
|
||||
}
|
||||
|
||||
/// A muted, in-gamut colour (L ≈ 46, a ≈ 10, b ≈ -20) and its Lab coordinates.
|
||||
const MUTED: [f32; 3] = [0.40, 0.42, 0.60];
|
||||
|
||||
#[test]
|
||||
fn lab_documents_invert_and_posterize_l_a_and_b() {
|
||||
let lab = photocraft_color::convert::srgb_to_lab(MUTED);
|
||||
for depth in SampleType::ALL {
|
||||
// Invert: every normalised channel n -> 1 - n, so L -> 100 - L and a, b -> their opposites
|
||||
// (-a - 1 on the 0..255 grid).
|
||||
let inv = lab_doc_adjusted(MUTED, Adjustment::Invert, depth);
|
||||
near3(inv, [100.0 - lab[0], -lab[1] - 1.0, -lab[2] - 1.0], 1.0, &format!("{depth:?} invert"));
|
||||
// An RGB invert would land elsewhere: lightness 100 - L is the Lab rule.
|
||||
let rgb_inv = photocraft_color::convert::srgb_to_lab(MUTED.map(|v| 1.0 - v));
|
||||
assert!((rgb_inv[0] - inv[0]).abs() > 2.0, "{depth:?}: {rgb_inv:?} vs {inv:?}");
|
||||
// Posterize, three levels: the same steps on L, a and b (here all land on the middle
|
||||
// step: L ≈ 50 and a, b ≈ -1, a neutral grey).
|
||||
let post = lab_doc_adjusted(MUTED, Adjustment::Posterize { levels: 3 }, depth);
|
||||
let n = [lab[0] / 100.0, (lab[1] + 128.0) / 255.0, (lab[2] + 128.0) / 255.0].map(|v| adjust::posterize(v, 3));
|
||||
let want = [n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0];
|
||||
assert!(want[0] > 45.0 && want[1].abs() < 2.0 && want[2].abs() < 2.0, "{want:?}");
|
||||
near3(post, want, 1.0, &format!("{depth:?} posterize"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_documents_brightness_contrast_moves_lightness_only() {
|
||||
let lab = photocraft_color::convert::srgb_to_lab(MUTED);
|
||||
for depth in SampleType::ALL {
|
||||
for legacy in [true, false] {
|
||||
let got = lab_doc_adjusted(MUTED, Adjustment::BrightnessContrast { brightness: 40.0, contrast: 20.0, legacy }, depth);
|
||||
assert!(got[0] > lab[0] + 5.0, "{depth:?} legacy {legacy}: {got:?}");
|
||||
near3([lab[0], got[1], got[2]], lab, 0.75, &format!("{depth:?} legacy {legacy}: a, b kept"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_documents_threshold_on_lightness_with_a_neutral_result() {
|
||||
for depth in SampleType::ALL {
|
||||
// A saturated yellow is light (L ≈ 97): white, not a coloured result.
|
||||
let yellow = lab_doc_adjusted([1.0, 1.0, 0.0], Adjustment::Threshold { level: 0.5 }, depth);
|
||||
near3(yellow, [100.0, 0.0, 0.0], 0.5, &format!("{depth:?} yellow"));
|
||||
// Pure blue has L ≈ 30: black.
|
||||
let blue = lab_doc_adjusted([0.0, 0.0, 1.0], Adjustment::Threshold { level: 0.5 }, depth);
|
||||
near3(blue, [0.0, 0.0, 0.0], 0.5, &format!("{depth:?} blue"));
|
||||
// Mid grey L 53.4 against level 128 (n 0.502): Lab L decides, not RGB luminance.
|
||||
let level = 0.52;
|
||||
let grey = lab_doc_adjusted([0.5; 3], Adjustment::Threshold { level }, depth);
|
||||
near3(grey, [100.0, 0.0, 0.0], 0.5, &format!("{depth:?} grey"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_documents_gradient_map_follows_lightness() {
|
||||
let stops = vec![(0.0, [0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 0.0])];
|
||||
// A grey and a saturated colour of the same L map to the same colour.
|
||||
let red = [0.9, 0.1, 0.1];
|
||||
let l = photocraft_color::convert::srgb_to_lab(red)[0];
|
||||
let grey = photocraft_color::convert::lab_to_srgb([l, 0.0, 0.0]);
|
||||
for depth in SampleType::ALL {
|
||||
for reverse in [false, true] {
|
||||
let adj = Adjustment::GradientMap { stops: stops.clone(), reverse, dither: false };
|
||||
let a = lab_doc_adjusted(red, adj.clone(), depth);
|
||||
let b = lab_doc_adjusted(grey, adj.clone(), depth);
|
||||
near3(a, b, 1.0, &format!("{depth:?} reverse {reverse}"));
|
||||
let t = if reverse { 1.0 - l / 100.0 } else { l / 100.0 };
|
||||
let want = photocraft_color::convert::srgb_to_lab([t, t, 1.0 - t]);
|
||||
near3(a, want, 1.5, &format!("{depth:?} reverse {reverse}"));
|
||||
}
|
||||
}
|
||||
// In RGB documents the map still follows RGB luminance: red and grey differ there.
|
||||
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [red[0], red[1], red[2], 1.0]);
|
||||
let adj = Adjustment::GradientMap { stops, reverse: false, dither: false };
|
||||
adjust::apply_in(&adj, &mut buf, adjust::Transfer::Srgb, None, ColorMode::Rgb);
|
||||
let mut grey_buf = Buffer::filled(Rect::new(0, 0, 1, 1), [grey[0], grey[1], grey[2], 1.0]);
|
||||
adjust::apply_in(&adj, &mut grey_buf, adjust::Transfer::Srgb, None, ColorMode::Rgb);
|
||||
assert!((buf.px[0][0] - grey_buf.px[0][0]).abs() > 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -636,6 +636,13 @@ pub struct Layer {
|
|||
}
|
||||
|
||||
impl Layer {
|
||||
/// Whether the layer has the Background's own properties: an unclipped pixel layer with
|
||||
/// transparency and position locked. Only the bottom layer of a document with them is the
|
||||
/// Background: use [`Document::background`] / [`Document::is_background`] to identify it.
|
||||
pub fn has_background_traits(&self) -> bool {
|
||||
self.locks.transparency && self.locks.position && !self.clipped && matches!(self.content, LayerContent::Raster(_))
|
||||
}
|
||||
|
||||
pub fn new(name: impl Into<String>, content: LayerContent) -> Self {
|
||||
Layer {
|
||||
id: LayerId::fresh(),
|
||||
|
|
@ -1040,6 +1047,25 @@ impl Document {
|
|||
self.path_of(id).map_or_else(Locks::default, |p| self.locks_at(&p))
|
||||
}
|
||||
|
||||
/// The Background layer, identified structurally as Photoshop does, never by its name (a file
|
||||
/// from a German or Japanese Photoshop names it "Hintergrund" or "背景"): the bottom layer of
|
||||
/// the stack, a pixel layer that is not clipped, with transparency and position locked (a PSD
|
||||
/// stores it without a transparency channel and transparency-protected; File › New and opaque
|
||||
/// flat files create it the same way).
|
||||
pub fn background(&self) -> Option<&Layer> {
|
||||
self.layers.first().filter(|l| Layer::has_background_traits(l))
|
||||
}
|
||||
|
||||
/// [`Self::background`], mutably.
|
||||
pub fn background_mut(&mut self) -> Option<&mut Layer> {
|
||||
self.layers.first_mut().filter(|l| Layer::has_background_traits(l))
|
||||
}
|
||||
|
||||
/// Whether `id` is the document's Background layer ([`Self::background`]).
|
||||
pub fn is_background(&self, id: LayerId) -> bool {
|
||||
self.background().is_some_and(|b| b.id == id)
|
||||
}
|
||||
|
||||
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
|
||||
fn rec(layers: &[Layer], id: LayerId) -> Option<&Layer> {
|
||||
layers.iter().find_map(|l| if l.id == id { Some(l) } else { l.children().and_then(|ch| rec(ch, id)) })
|
||||
|
|
@ -1205,6 +1231,30 @@ mod tests {
|
|||
assert_eq!(s.pixel(100, 0), vec![0.0; 4]);
|
||||
}
|
||||
|
||||
/// The Background is the locked bottom pixel layer, whatever it is called.
|
||||
#[test]
|
||||
fn background_is_structural_not_named() {
|
||||
let mut d = doc();
|
||||
let id = d.layers[0].id;
|
||||
assert!(d.is_background(id));
|
||||
for name in ["Hintergrund", "背景", "Arrière-plan"] {
|
||||
d.layers[0].name = name.into();
|
||||
assert_eq!(d.background().map(|l| l.id), Some(id), "{name}");
|
||||
}
|
||||
// A layer called "Background" without the locks, or above the bottom, is not it.
|
||||
let mut top = Layer::raster("Background", d.pixel_format());
|
||||
top.locks.transparency = true;
|
||||
top.locks.position = true;
|
||||
let top_id = top.id;
|
||||
d.layers.push(top);
|
||||
assert!(!d.is_background(top_id));
|
||||
d.layers[0].locks.position = false;
|
||||
assert!(d.background().is_none());
|
||||
d.layers[0].locks.position = true;
|
||||
d.layers[0].clipped = true;
|
||||
assert!(d.background_mut().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_group_depth_counts_nesting_without_recursing() {
|
||||
let mut d = doc();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@
|
|||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 4000).
|
||||
pub const MAX_EDGE: u32 = 30_000;
|
||||
|
||||
/// Most pixels a pattern may have to be painted. Renderers expand a pattern into a 16-byte-per-pixel
|
||||
/// working copy, so this keeps that copy at 1 GiB; larger patterns paint nothing.
|
||||
pub const MAX_PAINT_PIXELS: u64 = 1 << 26;
|
||||
|
||||
/// One pattern tile. Pixels cover `0..width × 0..height` of `surface`, in any pixel format
|
||||
/// (depth and colour model are the pattern's own, as in Photoshop).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
|
@ -57,6 +64,16 @@ impl Pattern {
|
|||
pub fn is_empty(&self) -> bool {
|
||||
self.width == 0 || self.height == 0
|
||||
}
|
||||
|
||||
/// Pixels in the tile (`width × height`, never overflowing).
|
||||
pub fn pixel_count(&self) -> u64 {
|
||||
u64::from(self.width) * u64::from(self.height)
|
||||
}
|
||||
|
||||
/// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`]).
|
||||
pub fn is_paintable(&self) -> bool {
|
||||
!self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS
|
||||
}
|
||||
}
|
||||
|
||||
/// Display form of a stored pattern name (see [`Pattern::display_name`]).
|
||||
|
|
|
|||
|
|
@ -428,6 +428,7 @@ fn hdr_toning(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let data: Vec<f32> = px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect();
|
||||
let mut bg = Layer::raster("Background", fmt);
|
||||
bg.locks.transparency = true;
|
||||
bg.locks.position = true;
|
||||
crate::pixels_mut(&mut bg)?.write_region(doc.bounds(), &data);
|
||||
*active = Some(bg.id);
|
||||
doc.layers = vec![bg];
|
||||
|
|
|
|||
|
|
@ -670,7 +670,7 @@ fn straighten(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return Ok(json!({"angle": 0.0}));
|
||||
}
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| l.name == "Background" && l.locks.position);
|
||||
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| d.is_background(l.id));
|
||||
let crop = p.get("crop").and_then(Value::as_bool).unwrap_or(active_bg || st.active_layer.is_none());
|
||||
let (w0, h0) = (f64::from(d.size.width), f64::from(d.size.height));
|
||||
compound(s, "Straighten", |s| {
|
||||
|
|
|
|||
|
|
@ -10,14 +10,13 @@ use photocraft_doc::{Document, Layer, LayerContent, LayerId};
|
|||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::extra_cmds::is_background;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "layer.new.backgroundFromLayer";
|
||||
|
||||
/// The document's Background layer, if it has one (always the bottom layer).
|
||||
pub(crate) fn background_id(doc: &Document) -> Option<LayerId> {
|
||||
doc.layers.first().filter(|l| is_background(l)).map(|l| l.id)
|
||||
doc.background().map(|l| l.id)
|
||||
}
|
||||
|
||||
/// Refuses an edit that moved the Background `bg` (taken before the edit) off the bottom of the
|
||||
|
|
@ -155,7 +154,7 @@ mod tests {
|
|||
assert_eq!(s.execute(CMD, json!({})).unwrap()["layer"], a.0);
|
||||
assert_eq!(order(&s), [a, l0, b], "{depth}: drops to the bottom");
|
||||
let l = &doc(&s).layers[0];
|
||||
assert!(is_background(l) && l.name == "Background", "{depth}");
|
||||
assert!(doc(&s).is_background(l.id) && l.name == "Background", "{depth}");
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(a));
|
||||
// Its pixels stay; transparency takes the background colour (blue).
|
||||
let red = pixel(&s, 3, 3);
|
||||
|
|
@ -170,10 +169,10 @@ mod tests {
|
|||
assert!(s.undo());
|
||||
assert_eq!(order(&s), [l0, a, b], "{depth}");
|
||||
let l = doc(&s).layer(a).unwrap();
|
||||
assert!(!is_background(l) && l.name == "A");
|
||||
assert!(!doc(&s).is_background(l.id) && l.name == "A");
|
||||
assert!(s.redo());
|
||||
assert_eq!(order(&s), [a, l0, b], "{depth}");
|
||||
assert!(is_background(&doc(&s).layers[0]));
|
||||
assert!(doc(&s).is_background(doc(&s).layers[0].id));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +190,7 @@ mod tests {
|
|||
assert_eq!(d.layers[1].id, l0);
|
||||
assert_eq!(d.layers[2].id, a);
|
||||
let bg = &d.layers[0];
|
||||
assert!(is_background(bg) && !bg.visible, "visibility is kept");
|
||||
assert!(d.is_background(bg.id) && !bg.visible, "visibility is kept");
|
||||
assert_eq!(bg.opacity, 1.0);
|
||||
}
|
||||
|
||||
|
|
@ -211,7 +210,7 @@ mod tests {
|
|||
for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": 99999}), json!({"layer": [a.0]})] {
|
||||
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert!(!doc(&s).layers.iter().any(is_background));
|
||||
assert!(doc(&s).background().is_none());
|
||||
// A group or an adjustment layer can't become the Background.
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
|
|
@ -220,7 +219,7 @@ mod tests {
|
|||
s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap();
|
||||
assert!(!s.is_enabled(CMD));
|
||||
assert!(s.execute(CMD, json!({})).is_err());
|
||||
assert!(!doc(&s).layers.iter().any(is_background));
|
||||
assert!(doc(&s).background().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -2,11 +2,15 @@
|
|||
//! has no menu item for them but lists them in Edit › Keyboard Shortcuts › Tools ("Decrease Brush
|
||||
//! Size"…); here they are ordinary commands with default shortcuts, so they can be rebound,
|
||||
//! recorded and driven by agents. Each one is a `tools.setBrush` on the session brush.
|
||||
//!
|
||||
//! `tools.rotateBrush` turns the tip: Photoshop's ← / → while painting (1°, ⇧ 15°). Arrows mean
|
||||
//! other things with other tools, so the shell gives them to it only for the painting tools
|
||||
//! (`move_mods::arrow_keys`).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, always};
|
||||
use crate::{Result, Session};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Photoshop's largest brush diameter, in pixels.
|
||||
pub const MAX_SIZE: f32 = 5000.0;
|
||||
|
|
@ -35,6 +39,23 @@ fn hardness(s: &mut Session, up: bool) -> Result<Value> {
|
|||
crate::brush_cmds::set_brush(s, &json!({ "brush": { "hardness": hardness } }))
|
||||
}
|
||||
|
||||
/// The brush angle turned by `degrees` (positive is anticlockwise, as the angle field counts),
|
||||
/// wrapped into Photoshop's -180..=180.
|
||||
fn turned(angle: f32, degrees: f32) -> f32 {
|
||||
let a = (angle + degrees).rem_euclid(360.0);
|
||||
if a > 180.0 { a - 360.0 } else { a }
|
||||
}
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let by = p
|
||||
.get("degrees")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|d| d.is_finite() && d.abs() <= 360.0)
|
||||
.ok_or_else(|| EngineError::BadParams { cmd: "tools.rotateBrush".into(), msg: "`degrees` must be a number from -360 to 360".into() })?;
|
||||
let angle = turned(s.tools.brush.angle, by as f32);
|
||||
crate::brush_cmds::set_brush(s, &json!({ "brush": { "angle": angle } }))
|
||||
}
|
||||
|
||||
macro_rules! key_cmd {
|
||||
($id:literal, $label:literal, $sc:literal, $run:expr) => {
|
||||
CommandSpec { id: $id, label: $label, menu: &[], shortcut: Some($sc), params: "{}", enabled: always, run: $run, journal: true }
|
||||
|
|
@ -47,6 +68,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
key_cmd!("tools.increaseBrushSize", "Increase Brush Size", "]", |s, _| size(s, true)),
|
||||
key_cmd!("tools.decreaseBrushHardness", "Decrease Brush Hardness", "Shift+[", |s, _| hardness(s, false)),
|
||||
key_cmd!("tools.increaseBrushHardness", "Increase Brush Hardness", "Shift+]", |s, _| hardness(s, true)),
|
||||
CommandSpec {
|
||||
id: "tools.rotateBrush",
|
||||
label: "Rotate Brush Tip",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"degrees":-360..360 (positive anticlockwise; ← / → while painting send 1, ⇧ 15)}"##,
|
||||
enabled: always,
|
||||
run: rotate,
|
||||
journal: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -74,6 +105,21 @@ mod tests {
|
|||
assert_eq!(hardness_step(0.0, false), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_brush_turns_the_tip_and_wraps_at_180() {
|
||||
assert_eq!(turned(0.0, 1.0), 1.0);
|
||||
assert_eq!(turned(179.0, 15.0), -166.0);
|
||||
assert_eq!(turned(-175.0, -15.0), 170.0);
|
||||
assert_eq!(turned(180.0, 0.0), 180.0);
|
||||
let mut s = Session::new();
|
||||
s.execute("tools.rotateBrush", json!({"degrees": -15})).unwrap();
|
||||
assert_eq!(s.tools.brush.angle, -15.0);
|
||||
for bad in [json!({}), json!({"degrees": "x"}), json!({"degrees": 1e9}), json!(null)] {
|
||||
assert!(s.execute("tools.rotateBrush", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.tools.brush.angle, -15.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_step_the_session_brush_and_journal() {
|
||||
let mut s = Session::new();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
//! Groups are the panel's top-level folders ([`BrushPreset::group`]); a group can hold nested
|
||||
//! folders ([`BrushPreset::folder`], a path inside the group, e.g. the folders of an imported
|
||||
//! `.abr` file). The group commands take an optional `folder` path to act on a nested folder
|
||||
//! instead of the whole group. A folder exists while a preset is in it (or below it).
|
||||
//! instead of the whole group, and `moveGroup` can move a group or folder `into` another folder
|
||||
//! or group, or out to the top level (dragging a folder onto a folder in Photoshop's panel). A
|
||||
//! folder exists while a preset is in it (or below it).
|
||||
//!
|
||||
//! The library order is the panel order: groups appear in order of their first preset, and a
|
||||
//! group's folders and presets in order of their first preset too. Every
|
||||
|
|
@ -247,9 +249,116 @@ fn move_folder(s: &mut Session, p: &Value, g: String, f: Vec<String>, cmd: &str)
|
|||
Ok(json!({ "group": g, "folder": f, "index": at }))
|
||||
}
|
||||
|
||||
/// Where `into` puts a folder: `Some(None)` = the top level (it becomes a group), `Some(Some((group,
|
||||
/// folder)))` = inside that folder of that group (`[]`: directly in the group). `None` without `into`.
|
||||
type Dest = Option<(String, Vec<String>)>;
|
||||
|
||||
fn into_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Dest>> {
|
||||
let into = match p.get("into") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v @ Value::Object(_)) => v,
|
||||
Some(_) => return Err(bad(cmd, "`into` must be an object {group?, folder?}")),
|
||||
};
|
||||
let Some(g) = group_param(into, "group", cmd)? else {
|
||||
if folder_param(into, cmd)?.is_some_and(|f| !f.is_empty()) {
|
||||
return Err(bad(cmd, "`into.folder` needs `into.group`"));
|
||||
}
|
||||
return Ok(Some(None));
|
||||
};
|
||||
group_block(s, &g, cmd)?;
|
||||
let f = folder_param(into, cmd)?.unwrap_or_default();
|
||||
if !s.tools.presets.iter().any(|x| in_folder(x, &g, &f)) {
|
||||
return Err(bad(cmd, format!("no folder `{}` in brush preset group `{g}`", f.join("/"))));
|
||||
}
|
||||
Ok(Some(Some((g, f))))
|
||||
}
|
||||
|
||||
/// Move folder `f` of group `g` (the whole group when `f` is empty) into `dest`, keeping its
|
||||
/// content and the order of its presets: Photoshop's drag of a folder onto another folder. It
|
||||
/// lands at the end of the destination, or before the sibling folder (group at the top level)
|
||||
/// named by `before`.
|
||||
fn move_into(s: &mut Session, p: &Value, g: String, f: Vec<String>, dest: Dest, cmd: &str) -> Result<Value> {
|
||||
if p.get("index").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad(cmd, "`index` can't be combined with `into` (use `before`)"));
|
||||
}
|
||||
let leaf = match f.last() {
|
||||
Some(l) => l.clone(),
|
||||
None if g.is_empty() => return Err(bad(cmd, "the ungrouped presets are not a folder that can be moved")),
|
||||
None => g.clone(),
|
||||
};
|
||||
let moving = |x: &BrushPreset| in_folder(x, &g, &f);
|
||||
let rest_of = |x: &BrushPreset| x.folder.get(f.len()..).unwrap_or_default().to_vec();
|
||||
let before = match p.get("before") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(b)) => Some(b.trim().to_string()),
|
||||
Some(_) => return Err(bad(cmd, "`before` must be a folder name")),
|
||||
};
|
||||
// The destination's own folders and groups, without the one being moved.
|
||||
let others: Vec<BrushPreset> = s.tools.presets.iter().filter(|x| !moving(x)).cloned().collect();
|
||||
let (new_group, new_prefix, siblings) = match &dest {
|
||||
None => {
|
||||
if f.is_empty() {
|
||||
return Err(bad(cmd, "`into`: the group is already at the top level"));
|
||||
}
|
||||
(leaf.clone(), Vec::new(), group_order(&others))
|
||||
}
|
||||
Some((dg, df)) => {
|
||||
if *dg == g && df.starts_with(&f) {
|
||||
return Err(bad(cmd, "`into` names the folder being moved or one of its own folders"));
|
||||
}
|
||||
let mut prefix = df.clone();
|
||||
prefix.push(leaf.clone());
|
||||
(dg.clone(), prefix, subfolder_order(&others, dg, df))
|
||||
}
|
||||
};
|
||||
if siblings.contains(&leaf) {
|
||||
return Err(bad(cmd, format!("a folder named `{leaf}` already exists there")));
|
||||
}
|
||||
if let Some(b) = &before
|
||||
&& !siblings.contains(b)
|
||||
{
|
||||
return Err(bad(cmd, format!("`before`: no folder named `{b}` there")));
|
||||
}
|
||||
let deepest = s.tools.presets.iter().filter(|x| moving(x)).map(|x| new_prefix.len() + rest_of(x).len()).max().unwrap_or(0);
|
||||
if deepest > MAX_FOLDER_DEPTH {
|
||||
return Err(bad(cmd, format!("folders can be nested at most {MAX_FOLDER_DEPTH} deep")));
|
||||
}
|
||||
let lib = std::mem::take(&mut s.tools.presets);
|
||||
let (mut block, mut rest): (Vec<BrushPreset>, Vec<BrushPreset>) = lib.into_iter().partition(|x| in_folder(x, &g, &f));
|
||||
for x in &mut block {
|
||||
let tail = rest_of(x);
|
||||
x.group.clone_from(&new_group);
|
||||
x.folder = new_prefix.iter().cloned().chain(tail).collect();
|
||||
x.builtin = false;
|
||||
}
|
||||
let pos = match (&dest, &before) {
|
||||
(None, Some(b)) => rest.iter().position(|x| x.group == *b),
|
||||
(None, None) => None,
|
||||
(Some((dg, df)), Some(b)) => {
|
||||
let sib: Vec<String> = df.iter().cloned().chain(std::iter::once(b.clone())).collect();
|
||||
rest.iter().position(|x| in_folder(x, dg, &sib))
|
||||
}
|
||||
(Some((dg, df)), None) => rest.iter().rposition(|x| in_folder(x, dg, df)).map(|k| k + 1),
|
||||
};
|
||||
let tail = rest.split_off(pos.unwrap_or(rest.len()).min(rest.len()));
|
||||
rest.extend(block);
|
||||
rest.extend(tail);
|
||||
s.tools.presets = rest;
|
||||
s.brush_presets_changed();
|
||||
let folder = new_prefix.get(..new_prefix.len().saturating_sub(1)).unwrap_or_default();
|
||||
let at = match &dest {
|
||||
None => group_order(&s.tools.presets).iter().position(|x| *x == new_group),
|
||||
Some(_) => subfolder_order(&s.tools.presets, &new_group, folder).iter().position(|x| *x == leaf),
|
||||
};
|
||||
Ok(json!({ "group": new_group, "folder": new_prefix, "index": at.unwrap_or(0) }))
|
||||
}
|
||||
|
||||
fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.moveGroup";
|
||||
let (g, f) = target_folder(s, p, cmd)?;
|
||||
if let Some(dest) = into_param(s, p, cmd)? {
|
||||
return move_into(s, p, g, f, dest, cmd);
|
||||
}
|
||||
if !f.is_empty() {
|
||||
return move_folder(s, p, g, f, cmd);
|
||||
}
|
||||
|
|
@ -348,7 +457,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"brush.presets.moveGroup",
|
||||
"Move Brush Group",
|
||||
r##"{"group":string,"folder":[name]? (move this nested folder among its sibling folders instead),"before":group? (or sibling folder name) | "index":n? (group or sibling folder position)=end} → {group, folder?, index}"##,
|
||||
r##"{"group":string,"folder":[name]? (move this nested folder instead),"into":{"group":string?,"folder":[name]?}? (move it inside that folder of that group, [] = directly in the group; no group = out to the top level as a group of its own; the folder keeps its content),"before":group? (or sibling folder name, in its new place with `into`) | "index":n? (group or sibling folder position, without `into`)=end} → {group, folder?, index}"##,
|
||||
move_group
|
||||
),
|
||||
spec!(
|
||||
|
|
@ -572,4 +681,84 @@ mod tests {
|
|||
assert_eq!(s.tools.presets, before);
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink", "Fine"], "index": u64::MAX})).unwrap();
|
||||
}
|
||||
|
||||
fn paths(s: &Session) -> Vec<String> {
|
||||
s.tools.presets.iter().filter(|x| !x.builtin).map(|x| format!("{}:{}:{}", x.group, x.folder.join("/"), x.name)).collect()
|
||||
}
|
||||
|
||||
/// `with_folders` plus group "Other" holding O1, and folder Box (B1) in it.
|
||||
fn with_two_groups() -> Session {
|
||||
let mut s = with_folders();
|
||||
for (n, f) in [("O1", &[][..]), ("B1", &["Box"][..])] {
|
||||
let folder = f.iter().map(|x| x.to_string()).collect();
|
||||
s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: "Other".into(), folder });
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_move_into_folders_and_groups() {
|
||||
let mut s = with_two_groups();
|
||||
// A nested folder into a sibling folder: its subtree follows, order kept.
|
||||
let r = s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Set", "folder": ["Ink", "Fine"]}})).unwrap();
|
||||
assert_eq!(r, json!({"group": "Set", "folder": ["Ink", "Fine", "Dry"], "index": 0}));
|
||||
assert_eq!(folder_of(&s, "D1"), ["Ink", "Fine", "Dry"]);
|
||||
// Into another group, before one of its folders.
|
||||
let r = s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink"], "into": {"group": "Other"}, "before": "Box"})).unwrap();
|
||||
assert_eq!(r["folder"], json!(["Ink"]));
|
||||
assert_eq!(paths(&s), ["Set::S0", "Other::O1", "Other:Ink:I1", "Other:Ink/Fine:F1", "Other:Ink/Fine/Dry:D1", "Other:Ink:I2", "Other:Box:B1"]);
|
||||
// Out to the top level: the folder becomes a group of its own.
|
||||
let r = s.execute("brush.presets.moveGroup", json!({"group": "Other", "folder": ["Ink", "Fine"], "into": {}})).unwrap();
|
||||
assert_eq!(r["group"], "Fine");
|
||||
assert_eq!(folder_of(&s, "D1"), ["Dry"]);
|
||||
assert_eq!(photocraft_paint::presets::find(&s.tools.presets, "F1").unwrap().group, "Fine");
|
||||
// A whole group into another group's folder.
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Fine", "into": {"group": "Set"}})).unwrap();
|
||||
assert!(names(&s, "Fine").is_empty());
|
||||
assert_eq!(folder_of(&s, "D1"), ["Fine", "Dry"]);
|
||||
assert_eq!(subfolder_order(&s.tools.presets, "Set", &[]), ["Fine"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_moves_into_folders_fail_without_changes() {
|
||||
let mut s = with_two_groups();
|
||||
// A chain 31 deep under Set/Deep.
|
||||
let deep: Vec<String> = std::iter::once("Deep".to_string()).chain((0..30).map(|i| format!("d{i}"))).collect();
|
||||
s.tools.presets.push(BrushPreset { name: "Z".into(), brush: Default::default(), builtin: false, group: "Set".into(), folder: deep.clone() });
|
||||
s.tools.presets.push(BrushPreset { name: "Y".into(), brush: Default::default(), builtin: false, group: "Other".into(), folder: vec!["Dry".into()] });
|
||||
let before = s.tools.presets.clone();
|
||||
let deep_json = json!(deep);
|
||||
for p in [
|
||||
// Into itself or its own descendant.
|
||||
json!({"group": "Set", "folder": ["Ink"], "into": {"group": "Set", "folder": ["Ink"]}}),
|
||||
json!({"group": "Set", "folder": ["Ink"], "into": {"group": "Set", "folder": ["Ink", "Fine"]}}),
|
||||
json!({"group": "Set", "into": {"group": "Set", "folder": ["Dry"]}}),
|
||||
json!({"group": "Set", "into": {"group": "Set"}}),
|
||||
// A group already at the top level; the ungrouped presets.
|
||||
json!({"group": "Set", "into": {}}),
|
||||
// A name clash at the destination.
|
||||
json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Other"}}),
|
||||
json!({"group": "Other", "folder": ["Box"], "into": {"group": "Set", "folder": ["Ink"]}, "before": "Nope"}),
|
||||
json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Set", "folder": ["Ink"]}, "index": 0}),
|
||||
// Missing destinations and bad shapes.
|
||||
json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Nope"}}),
|
||||
json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Other", "folder": ["Nope"]}}),
|
||||
json!({"group": "Set", "folder": ["Dry"], "into": {"folder": ["Box"]}}),
|
||||
json!({"group": "Set", "folder": ["Dry"], "into": "Other"}),
|
||||
json!({"group": "Set", "folder": ["Dry"], "into": {"group": 5}}),
|
||||
json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Other"}, "before": 3}),
|
||||
// Deeper than MAX_FOLDER_DEPTH once moved (Ink/Fine is 2 deep: 2 + 31 > 32).
|
||||
json!({"group": "Set", "folder": ["Deep"], "into": {"group": "Set", "folder": ["Ink", "Fine"]}}),
|
||||
json!({"group": "Set", "folder": ["Ink"], "into": {"group": "Set", "folder": deep_json}}),
|
||||
] {
|
||||
assert!(s.execute("brush.presets.moveGroup", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.tools.presets, before);
|
||||
// Ink/Fine into Set itself: a clash with nothing, so it moves up a level.
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink", "Fine"], "into": {"group": "Set"}})).unwrap();
|
||||
assert_eq!(folder_of(&s, "F1"), ["Fine"]);
|
||||
// An ungrouped preset's group can't move.
|
||||
s.tools.presets.push(BrushPreset { name: "U".into(), brush: Default::default(), builtin: false, group: String::new(), folder: Vec::new() });
|
||||
assert!(s.execute("brush.presets.moveGroup", json!({"group": "", "into": {"group": "Set"}})).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1126,11 +1126,15 @@ fn delete_channel(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
_ => return Err(bad(cmd, "missing `channel`")),
|
||||
},
|
||||
};
|
||||
// `edit` validates the view against the shorter channel list before we can remap it.
|
||||
// Keep the original indices and eyes so a surviving final channel is not discarded.
|
||||
let view = s.active().ok_or(EngineError::NoDocument)?.channel_view.clone();
|
||||
s.edit("Delete Channel", |doc, _| {
|
||||
doc.channels.remove(i);
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some(st) = s.active_mut() {
|
||||
st.channel_view = view;
|
||||
let v = &mut st.channel_view;
|
||||
if i < v.alpha_visible.len() {
|
||||
v.alpha_visible.remove(i);
|
||||
|
|
@ -1804,6 +1808,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
journal: false,
|
||||
},
|
||||
spec!("channel.target.composite", "Target Composite Channel", [], Some("Cmd+2"), "{}", has_doc, |s, _| target_slot(s, 2)),
|
||||
// ⌘\ targets the active layer's mask, as clicking its thumbnail does.
|
||||
spec!("channel.target.layerMask", "Target Layer Mask", [], Some("Cmd+\\"), "{}", has_layer_mask, |s, _| target_cmd(
|
||||
s,
|
||||
&serde_json::json!({"channel": "mask"})
|
||||
)),
|
||||
// ⌘⌥2 loads the image's luminosity as a selection (⌘-click on the composite channel).
|
||||
spec!("select.loadLuminosity", "Load Luminosity", [], Some("Cmd+Alt+2"), "{}", has_doc, |s, _| load_selection(
|
||||
s,
|
||||
&serde_json::json!({"channel": "composite"})
|
||||
)),
|
||||
spec!("channel.target.slot3", "Target Channel 3", [], Some("Cmd+3"), "{}", slot_ok::<3>, |s, _| target_slot(s, 3)),
|
||||
spec!("channel.target.slot4", "Target Channel 4", [], Some("Cmd+4"), "{}", slot_ok::<4>, |s, _| target_slot(s, 4)),
|
||||
spec!("channel.target.slot5", "Target Channel 5", [], Some("Cmd+5"), "{}", slot_ok::<5>, |s, _| target_slot(s, 5)),
|
||||
|
|
@ -1814,6 +1828,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
]
|
||||
}
|
||||
|
||||
/// Target Layer Mask: the active layer has a mask.
|
||||
fn has_layer_mask(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
let layer = st.active_layer.and_then(|id| st.doc.layer(id)).ok_or("no active layer")?;
|
||||
layer.mask.as_ref().map(|_| ()).ok_or_else(|| "the active layer has no layer mask".to_string())
|
||||
}
|
||||
|
||||
fn slot_ok<const N: usize>(s: &Session) -> std::result::Result<(), String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
let colors = color_count(&d.doc);
|
||||
|
|
|
|||
|
|
@ -168,6 +168,56 @@ fn channel_management_and_view_state() {
|
|||
assert_eq!(doc(&s).channels.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_an_earlier_channel_keeps_the_last_channel_targeted() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = session_depth(depth);
|
||||
s.execute("channel.new", json!({"name": "Remove", "fill": "black"})).unwrap();
|
||||
s.execute("channel.new", json!({"name": "Keep", "fill": "white"})).unwrap();
|
||||
let layer_before = px(&s, 5, 5);
|
||||
s.execute("channel.delete", json!({"channel": "Remove"})).unwrap();
|
||||
assert_eq!(doc(&s).channels[0].name, "Keep");
|
||||
let view = &s.active().unwrap().channel_view;
|
||||
assert_eq!(view.target, ChannelTarget::Alpha(0), "the surviving target follows its new index");
|
||||
assert!(view.alpha_shown(0));
|
||||
assert_eq!(view.visible_colors(3), 0, "keep the channel-only view");
|
||||
|
||||
s.execute("image.adjustments.invert", json!({})).unwrap();
|
||||
assert_eq!(chan(&s, 0, 5, 5), 0.0, "implicit pixel edits still target Keep");
|
||||
assert_eq!(px(&s, 5, 5), layer_before, "the layer must not be edited instead");
|
||||
assert!(s.undo());
|
||||
assert_eq!(chan(&s, 0, 5, 5), 1.0);
|
||||
assert_eq!(px(&s, 5, 5), layer_before);
|
||||
assert!(s.redo());
|
||||
assert_eq!(chan(&s, 0, 5, 5), 0.0);
|
||||
assert_eq!(px(&s, 5, 5), layer_before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_channel_remaps_all_surviving_eyes_and_targets() {
|
||||
for target_index in 0..3 {
|
||||
let mut s = session();
|
||||
for name in ["First", "Middle", "Last"] {
|
||||
s.execute("channel.new", json!({"name": name})).unwrap();
|
||||
}
|
||||
s.execute("channel.target", json!({"channel": target_index})).unwrap();
|
||||
for (channel, visible) in [false, false, true].into_iter().enumerate() {
|
||||
s.execute("channel.setVisible", json!({"channel": channel, "visible": visible})).unwrap();
|
||||
}
|
||||
s.execute("channel.delete", json!({"channel": "Middle"})).unwrap();
|
||||
let view = &s.active().unwrap().channel_view;
|
||||
let expected = match target_index {
|
||||
0 => ChannelTarget::Alpha(0),
|
||||
1 => ChannelTarget::Composite,
|
||||
_ => ChannelTarget::Alpha(1),
|
||||
};
|
||||
assert_eq!(view.target, expected, "old target {target_index}");
|
||||
assert!(!view.alpha_shown(0));
|
||||
assert!(view.alpha_shown(1), "Last keeps its eye when Middle is removed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_colour_channel_can_hide_while_alpha_stays() {
|
||||
let mut s = session();
|
||||
|
|
@ -741,6 +791,33 @@ fn clear_with_an_alpha_channel_or_quick_mask_targeted_fills_it_not_the_layer() {
|
|||
assert_eq!(px(&s, 5, 5), layer, "the layer is untouched in Quick Mask mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_backslash_targets_the_layer_mask_and_command_2_the_composite() {
|
||||
let mut s = session();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
assert!(!s.is_enabled("channel.target.layerMask"), "no mask yet");
|
||||
assert!(s.execute("channel.target.layerMask", json!({})).is_err());
|
||||
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
s.execute("channel.target.composite", json!({})).unwrap();
|
||||
assert_eq!(crate::commands::find("channel.target.layerMask").and_then(|c| c.shortcut), Some("Cmd+\\"));
|
||||
s.execute("channel.target.layerMask", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().channel_view.target, ChannelTarget::LayerMask);
|
||||
s.execute("channel.target.composite", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().channel_view.target, ChannelTarget::Composite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_alt_2_loads_the_images_luminosity() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0, 0, 20, 20, "replace");
|
||||
s.execute("edit.fill", json!({"contents": "black"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
assert_eq!(crate::commands::find("select.loadLuminosity").and_then(|c| c.shortcut), Some("Cmd+Alt+2"));
|
||||
s.execute("select.loadLuminosity", json!({})).unwrap();
|
||||
assert!(sel_at(&s, 5, 5) < 0.01, "black: unselected");
|
||||
assert!(sel_at(&s, 30, 5) > 0.99, "white: selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_selection_shape_without_pixel_cache_uses_its_transparency() {
|
||||
for depth in [8, 16, 32] {
|
||||
|
|
|
|||
|
|
@ -276,18 +276,22 @@ impl ColorState {
|
|||
if !mismatch || linear_hdr {
|
||||
return merge(base, json!({"action": "kept"}));
|
||||
}
|
||||
// An input-only profile can't be the document's space: nothing could be
|
||||
// composited into it (#3876). Preserve converts it to the working space instead.
|
||||
let input_only = !emb.can_be_destination();
|
||||
let action = match policy {
|
||||
Policy::Preserve => "kept",
|
||||
Policy::Preserve if !input_only => "kept",
|
||||
Policy::Off => {
|
||||
doc.icc_profile = None;
|
||||
"discarded"
|
||||
}
|
||||
Policy::Convert => match convert_document(doc, &working, self.settings.intent(), self.settings.bpc) {
|
||||
Policy::Preserve | Policy::Convert => match convert_document(doc, &working, self.settings.intent(), self.settings.bpc) {
|
||||
Ok(()) => "converted",
|
||||
Err(_) => "kept",
|
||||
},
|
||||
};
|
||||
merge(base, json!({"action": action, "ask": self.settings.ask_on_mismatch}))
|
||||
let report = merge(base, json!({"action": action, "ask": self.settings.ask_on_mismatch}));
|
||||
if input_only { merge(report, json!({"inputOnly": true})) } else { report }
|
||||
}
|
||||
None => {
|
||||
// Untagged files are assumed to be in the working space; tag them when it is not
|
||||
|
|
@ -1397,6 +1401,58 @@ mod settings_tests {
|
|||
assert_eq!(desc(&s.active().unwrap().doc).as_deref(), Some("Display P3"));
|
||||
}
|
||||
|
||||
/// Display P3 as an input profile with only an AToB table, like Apple's Wide Color Sharing
|
||||
/// Profile embedded in iPhone photos.
|
||||
fn input_only_p3() -> Profile {
|
||||
let p3 = Builtin::DisplayP3.profile();
|
||||
let t = Transform::new(p3, Builtin::LabD50.profile(), Intent::RelativeColorimetric, false).unwrap();
|
||||
let clut = photocraft_cms::Clut::sample(vec![9; 3], 3, |inp, out| t.eval(inp, out));
|
||||
let ident = || photocraft_cms::pipeline::Stage::Curves(vec![photocraft_cms::Curve::Identity; 3]);
|
||||
let lut = photocraft_cms::profile::Lut {
|
||||
kind: photocraft_cms::profile::LutKind::Ab,
|
||||
inputs: 3,
|
||||
outputs: 3,
|
||||
stages: vec![ident(), photocraft_cms::pipeline::Stage::Clut(clut), ident()],
|
||||
};
|
||||
let mut p = p3.clone();
|
||||
p.class = photocraft_cms::ProfileClass::Input;
|
||||
p.pcs = photocraft_cms::Pcs::Lab;
|
||||
p.description = "Input-only P3".into();
|
||||
(p.matrix, p.trc) = (None, None);
|
||||
p.a2b = [Some(lut), None, None];
|
||||
p.b2a = [None, None, None];
|
||||
p.with_encoded_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_only_profiles_are_converted_on_open() {
|
||||
// #3876: an iPhone photo's input-only profile was kept as the document's space, so
|
||||
// compositing anything into it failed with "RGB profile without matrix/TRC or BToA tags".
|
||||
let input = input_only_p3();
|
||||
assert!(Builtin::DisplayP3.profile().can_be_destination());
|
||||
assert!(!input.can_be_destination());
|
||||
let mut s = Session::new();
|
||||
let mut d = tagged("srgb");
|
||||
d.icc_profile = Some(input.to_bytes());
|
||||
let (photo, r) = s.open_document(d, None);
|
||||
assert_eq!((r["action"].as_str(), r["inputOnly"].as_bool(), r["ask"].as_bool()), (Some("converted"), Some(true), Some(true)));
|
||||
let doc = &s.active().unwrap().doc;
|
||||
assert!(desc(doc).unwrap().starts_with("sRGB"));
|
||||
// Converted from P3, not reinterpreted as sRGB.
|
||||
assert!((doc.layers[0].surface().unwrap().pixel(1, 1)[0] - 0.2).abs() > 0.01);
|
||||
let (other, _) = s.open_document(tagged("srgb"), None);
|
||||
s.execute("layer.copyToDocument", json!({"document": photo, "source": other})).unwrap();
|
||||
// Off still discards the profile, and profiles that can be a destination are still kept.
|
||||
s.execute("edit.colorSettings", json!({"policyRgb": "off"})).unwrap();
|
||||
let mut d = tagged("srgb");
|
||||
d.icc_profile = Some(input.to_bytes());
|
||||
let (_, r) = s.open_document(d, None);
|
||||
assert_eq!((r["action"].as_str(), r["inputOnly"].as_bool()), (Some("discarded"), Some(true)));
|
||||
s.execute("edit.colorSettings", json!({"policyRgb": "preserve"})).unwrap();
|
||||
let (_, r) = s.open_document(tagged("display-p3"), None);
|
||||
assert_eq!((r["action"].as_str(), r.get("inputOnly")), (Some("kept"), None));
|
||||
}
|
||||
|
||||
/// A little-endian EXIF block whose Exif IFD holds ColorSpace = `space`.
|
||||
fn exif_color_space(space: u16) -> Vec<u8> {
|
||||
let mut b = b"Exif\0\0II\x2a\0\x08\0\0\0".to_vec();
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ fn enabled(s: &Session) -> std::result::Result<(), String> {
|
|||
if locks.pixels || locks.all {
|
||||
return Err(format!("the layer \"{}\" is locked", l.name));
|
||||
}
|
||||
if locks.transparency && !crate::extra_cmds::is_background(l) {
|
||||
if locks.transparency && !d.doc.is_background(l.id) {
|
||||
return Err(format!("the transparency of layer \"{}\" is locked", l.name));
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -217,7 +217,7 @@ mod tests {
|
|||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(name(&s), "Background");
|
||||
let d = s.active().unwrap();
|
||||
assert!(crate::extra_cmds::is_background(d.doc.layer(d.active_layer.unwrap()).unwrap()));
|
||||
assert!(d.doc.is_background(d.active_layer.unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -136,6 +136,19 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
®istry().specs
|
||||
}
|
||||
|
||||
/// One `command.list` / `engine.commands` row for `c`, given why it can't run now (`None` = it can).
|
||||
/// `disabledReason` is that message; `needsDocument` is true when the reason is that no document is
|
||||
/// open (#3664), so an agent can tell "open or create a document first" from a precondition that a
|
||||
/// document alone won't meet (a selection, an empty clipboard).
|
||||
pub fn list_entry(s: &Session, c: &CommandSpec, reason: Option<String>) -> Value {
|
||||
let needs_document =
|
||||
s.active().is_none() && reason.as_deref().is_some_and(|r| ["no document", "no active document", "no open document"].iter().any(|p| r.starts_with(p)));
|
||||
json!({
|
||||
"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params,
|
||||
"enabled": reason.is_none(), "disabledReason": reason, "needsDocument": needs_document,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find(id: &str) -> Option<&'static CommandSpec> {
|
||||
let registry = registry();
|
||||
registry.by_id.get(id).and_then(|&index| registry.specs.get(index))
|
||||
|
|
@ -761,7 +774,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
below.push(idx - 1);
|
||||
let lower = doc.layer_at(&below).ok_or(EngineError::NoLayer(id))?.clone();
|
||||
let upper = doc.layer(id).ok_or(EngineError::NoLayer(id))?.clone();
|
||||
let merged = pixels::merge_down(doc.bounds(), &lower, &upper, doc.pixel_format());
|
||||
let merged = pixels::merge_down(doc, &lower, &upper);
|
||||
doc.remove(id);
|
||||
*doc.layer_at_mut(&below).ok_or(EngineError::NoLayer(lower.id))? = merged;
|
||||
*active = Some(lower.id);
|
||||
|
|
@ -769,6 +782,13 @@ fn build() -> Vec<CommandSpec> {
|
|||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("layer.mergeGroup", "Merge Group", ["Layer"], None, r##"{"layer":id?}"##, has_layer, |s, p| {
|
||||
let id = layer_param(s, p)?;
|
||||
if !s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Group(_))) {
|
||||
return Err(EngineError::Other("Merge Group needs a group".into()));
|
||||
}
|
||||
merge_group(s, id)
|
||||
}),
|
||||
cmd!("layer.flattenImage", "Flatten Image", ["Layer"], None, "{}", has_doc, |s, _| {
|
||||
s.edit("Flatten Image", |doc, active| {
|
||||
let fmt = doc.pixel_format();
|
||||
|
|
@ -1014,18 +1034,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
shortcut: None,
|
||||
params: "{}",
|
||||
enabled: always,
|
||||
run: |s, _| {
|
||||
Ok(Value::Array(
|
||||
command_specs()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(s).is_ok(),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
},
|
||||
run: |s, _| Ok(Value::Array(command_specs().iter().map(|c| list_entry(s, c, (c.enabled)(s).err())).collect())),
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
|
|
@ -1416,7 +1425,7 @@ pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
|
|||
pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result<Layer> {
|
||||
let src = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
// A copy of the Background layer is an ordinary, unlocked layer (Photoshop).
|
||||
let from_background = src.name == "Background" && src.locks.transparency && doc.layers.first().is_some_and(|b| b.id == id);
|
||||
let from_background = doc.is_background(id);
|
||||
let mut dup = src.duplicate();
|
||||
dup.name = doc.copy_name(&dup.name);
|
||||
if from_background {
|
||||
|
|
@ -1425,6 +1434,37 @@ pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result<Layer> {
|
|||
Ok(dup)
|
||||
}
|
||||
|
||||
/// Layer › Merge Group (⌘E with a group selected, #3832): the group's visible contents, as they
|
||||
/// show, become one pixel layer in its place, named after it. Like Photoshop, the layer keeps the
|
||||
/// group's opacity, blend mode (Pass Through becomes Normal), clipping and locks; the group's mask
|
||||
/// and style are applied to the pixels.
|
||||
fn merge_group(s: &mut Session, id: LayerId) -> Result<Value> {
|
||||
s.edit("Merge Group", |doc, active| {
|
||||
let group = doc.layer(id).ok_or(EngineError::NoLayer(id))?.clone();
|
||||
if !group.visible {
|
||||
return Err(EngineError::Other("the group is hidden".into()));
|
||||
}
|
||||
let fmt = doc.pixel_format();
|
||||
let fmt = photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true);
|
||||
let mut solo = group.clone();
|
||||
solo.opacity = 1.0;
|
||||
solo.blend = photocraft_doc::BlendMode::Normal;
|
||||
solo.clipped = false;
|
||||
let mut tmp = doc.clone();
|
||||
tmp.layers = vec![solo];
|
||||
let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers(&tmp, fmt, None)));
|
||||
merged.id = group.id;
|
||||
merged.opacity = group.opacity;
|
||||
merged.blend = if group.blend == photocraft_doc::BlendMode::PassThrough { photocraft_doc::BlendMode::Normal } else { group.blend };
|
||||
merged.clipped = group.clipped;
|
||||
merged.locks = group.locks;
|
||||
*doc.layer_mut(id).ok_or(EngineError::NoLayer(id))? = merged;
|
||||
*active = Some(id);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "layer": id.0 }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod registry_tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -213,15 +213,6 @@ fn smart_layer(s: &Session) -> std::result::Result<(), String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Unlocks a Background layer for an operation that exposes transparency (as Photoshop does).
|
||||
fn float_background(l: &mut Layer) {
|
||||
if l.locks.position && l.name == "Background" {
|
||||
l.locks.position = false;
|
||||
l.locks.transparency = false;
|
||||
l.name = "Layer 0".into();
|
||||
}
|
||||
}
|
||||
|
||||
fn check_locks(l: &Layer, group: Locks, position: bool) -> Result<()> {
|
||||
let locks = l.locks.union(group);
|
||||
if locks.all || locks.pixels || (position && locks.position) {
|
||||
|
|
@ -247,14 +238,15 @@ fn run_on_layer(s: &mut Session, cmd: &str, label: &str, p: &Value, moves: bool,
|
|||
let canvas = doc.bounds();
|
||||
let selection = doc.selection.clone();
|
||||
let group = crate::transform_cmds::group_locks(doc, id);
|
||||
// A Background gains transparency when the distortion moves pixels (as in Photoshop).
|
||||
if moves {
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
}
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let LayerContent::Smart(_) = l.content {
|
||||
let sf = SmartFilter { command: cmd.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
|
||||
return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref());
|
||||
}
|
||||
if moves {
|
||||
float_background(l);
|
||||
}
|
||||
check_locks(l, group, moves)?;
|
||||
let LayerContent::Raster(surf) = &mut l.content else {
|
||||
return Err(EngineError::Other(format!("{label} needs a pixel layer (rasterize it first)")));
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ fn whole_layers(d: &crate::DocState, ids: &[LayerId]) -> Option<Box<Document>> {
|
|||
let mut copies = scratch_doc(&d.doc);
|
||||
for id in ids {
|
||||
let mut l = d.doc.layer(*id)?.clone();
|
||||
if crate::extra_cmds::is_background(&l) {
|
||||
if d.doc.is_background(*id) {
|
||||
l.name = "Layer 0".into();
|
||||
l.locks = Default::default();
|
||||
}
|
||||
|
|
@ -277,7 +277,8 @@ fn clear_selected(doc: &mut Document, id: LayerId, background: [f32; 4]) -> Resu
|
|||
/// Cut / Clear on a layer: makes the selected pixels transparent. The Background can't hold
|
||||
/// transparency, so there the area is filled with the background colour instead (Photoshop).
|
||||
pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Option<&Surface>, background: [f32; 4]) -> Result<()> {
|
||||
let bg = crate::extra_cmds::is_background(doc.layer(id).ok_or(EngineError::NoLayer(id))?);
|
||||
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let bg = doc.is_background(id);
|
||||
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
|
||||
if bg {
|
||||
crate::pixels::try_fill_surface(surf, area, background, sel, true)?;
|
||||
|
|
@ -461,7 +462,7 @@ fn merge_visible(s: &mut Session) -> Result<Value> {
|
|||
}
|
||||
let lowest = visible[0];
|
||||
let base = doc.layers[lowest].clone();
|
||||
let is_background = base.name == "Background" && base.locks.transparency;
|
||||
let is_background = lowest == 0 && doc.is_background(base.id);
|
||||
// Composite of visible layers only (hidden ones stay where they are).
|
||||
let mut solo = doc.clone();
|
||||
solo.layers.retain(|l| l.visible);
|
||||
|
|
@ -1363,7 +1364,7 @@ mod tests {
|
|||
let mut s = fresh();
|
||||
s.execute(cmd, json!({})).unwrap();
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert!(crate::extra_cmds::is_background(&d.layers[0]), "{cmd}: still the Background");
|
||||
assert!(d.is_background(d.layers[0].id), "{cmd}: still the Background");
|
||||
assert_eq!(px(&s, 0, 8, 8), vec![1.0, 0.0, 0.0, 1.0], "{cmd}: cut area is the background colour");
|
||||
assert_eq!(px(&s, 0, 30, 20), vec![1.0, 1.0, 1.0, 1.0], "{cmd}: outside untouched");
|
||||
}
|
||||
|
|
@ -1609,9 +1610,9 @@ mod tests {
|
|||
let st = s.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), 4);
|
||||
let pasted = &st.doc.layers[st.doc.layers.len() - 2];
|
||||
assert!(!crate::extra_cmds::is_background(pasted));
|
||||
assert!(!st.doc.is_background(pasted.id));
|
||||
assert_eq!(pasted.locks, Default::default());
|
||||
assert!(crate::extra_cmds::is_background(&st.doc.layers[0]), "the real Background is untouched");
|
||||
assert!(st.doc.is_background(st.doc.layers[0].id), "the real Background is untouched");
|
||||
}
|
||||
|
||||
/// #2428: a smart object copied in one document pastes into another as a smart object, in
|
||||
|
|
|
|||
|
|
@ -59,19 +59,6 @@ fn active_pixel_layer(s: &Session, cmd: &str) -> Result<LayerId> {
|
|||
s.active().and_then(|d| d.active_layer).ok_or_else(|| bad(cmd, "no active layer"))
|
||||
}
|
||||
|
||||
fn is_background(l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
|
||||
}
|
||||
|
||||
/// Photoshop converts the Background to a normal layer before erasing to transparency.
|
||||
fn unlock_background(l: &mut Layer) {
|
||||
if is_background(l) {
|
||||
l.name = "Layer 0".into();
|
||||
l.locks.transparency = false;
|
||||
l.locks.position = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn pixels_locked(doc: &Document, l: &Layer) -> Option<EngineError> {
|
||||
let locks = doc.effective_locks(l.id);
|
||||
(locks.pixels || locks.all).then(|| EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)))
|
||||
|
|
@ -114,7 +101,9 @@ fn magic_eraser(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let bg = s.tools.background;
|
||||
let dmg = s.edit("Magic Eraser", |doc, _| {
|
||||
let selection = doc.selection.clone();
|
||||
unlock_background(doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?);
|
||||
// Photoshop converts the Background to a normal layer before erasing to transparency.
|
||||
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
let lock = doc.effective_locks(id).transparency;
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
|
||||
|
|
@ -149,15 +138,15 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if let Some(e) = pixels_locked(&d.doc, l) {
|
||||
return Err(e);
|
||||
}
|
||||
if d.doc.effective_locks(id).transparency && !is_background(l) {
|
||||
if d.doc.effective_locks(id).transparency && !d.doc.is_background(id) {
|
||||
return Err(EngineError::Other(format!("Could not use the Background Eraser because the transparency of layer \"{}\" is locked", l.name)));
|
||||
}
|
||||
}
|
||||
let stroke = Stroke { brush, points: pts };
|
||||
let dmg = s.edit("Background Eraser", |doc, _| {
|
||||
let selection = doc.selection.clone();
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
unlock_background(l);
|
||||
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
|
||||
Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref()))
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -43,13 +43,9 @@ fn has_clip_and_selection(s: &Session) -> std::result::Result<(), String> {
|
|||
d.doc.selection.as_ref().map(|_| ()).ok_or_else(|| "Paste Into needs a selection".into())
|
||||
}
|
||||
|
||||
pub(crate) fn is_background(l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
|
||||
}
|
||||
|
||||
fn has_background(s: &Session) -> std::result::Result<(), String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
d.doc.layers.first().filter(|l| is_background(l)).map(|_| ()).ok_or_else(|| "the document has no Background layer".into())
|
||||
d.doc.background().map(|_| ()).ok_or_else(|| "the document has no Background layer".into())
|
||||
}
|
||||
|
||||
/// The layers a layer-style command acts on: as [`crate::layer_multi_cmds::targets`], but with
|
||||
|
|
@ -60,7 +56,7 @@ pub(crate) fn style_targets(s: &Session, p: &Value, fits: impl Fn(&Layer) -> boo
|
|||
return Ok(ids);
|
||||
}
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !is_background(l) && fits(l))).collect();
|
||||
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !d.doc.is_background(l.id) && fits(l))).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("none of the selected layers can take this layer style change".into()));
|
||||
}
|
||||
|
|
@ -441,7 +437,7 @@ pub fn reveal_all_bounds(doc: &Document) -> Rect {
|
|||
|
||||
fn layer_from_background(s: &mut Session) -> Result<Value> {
|
||||
let id = s.edit("Layer From Background", |doc, active| {
|
||||
let l = doc.layers.first_mut().filter(|l| is_background(l)).ok_or(EngineError::Other("the document has no Background layer".into()))?;
|
||||
let l = doc.background_mut().ok_or(EngineError::Other("the document has no Background layer".into()))?;
|
||||
unlock_background(l);
|
||||
*active = Some(l.id);
|
||||
Ok(l.id)
|
||||
|
|
@ -458,7 +454,14 @@ pub(crate) fn unlock_background(l: &mut Layer) {
|
|||
/// Before `id` gets a layer mask: the Background can't have one, so it becomes a normal layer
|
||||
/// first ("Layer 0"), as when adding a mask to it in Photoshop. Other layers are left alone.
|
||||
pub(crate) fn background_to_layer_for_mask(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) {
|
||||
if let Some(l) = doc.layers.first_mut().filter(|l| l.id == id && is_background(l)) {
|
||||
background_to_layer(doc, id);
|
||||
}
|
||||
|
||||
/// If `id` is the Background ([`photocraft_doc::Document::background`], whatever its name), turns
|
||||
/// it into a normal layer ("Layer 0"), as Photoshop does before an edit the Background can't
|
||||
/// take (moving, transforming, exposing transparency, a style). Other layers are left alone.
|
||||
pub(crate) fn background_to_layer(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) {
|
||||
if let Some(l) = doc.background_mut().filter(|l| l.id == id) {
|
||||
unlock_background(l);
|
||||
}
|
||||
}
|
||||
|
|
@ -563,6 +566,35 @@ fn set_visible(s: &mut Session, p: &Value, visible: bool) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Layer › Hide Layers (⌘,): hide the targeted layers, or show them when every one is already
|
||||
/// hidden, as Photoshop's item does (it reads Show Layers then), so ⌘, toggles.
|
||||
fn hide_or_show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let targets = if crate::layer_multi_cmds::multi(s, p) { crate::layer_multi_cmds::selected(s) } else { vec![layer_param(s, p)?] };
|
||||
let all_hidden = targets.iter().all(|id| st.doc.layer(*id).is_some_and(|l| !l.visible));
|
||||
set_visible(s, p, all_hidden)
|
||||
}
|
||||
|
||||
/// `/`: Lock Transparent Pixels on or off for the targeted layers, as the Layers panel's first lock
|
||||
/// button does. With several layers targeted, any unlocked one means lock them all. The
|
||||
/// Background, which can't take this lock, is left out.
|
||||
fn toggle_lock_transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let targets: Vec<LayerId> = if crate::layer_multi_cmds::multi(s, p) { crate::layer_multi_cmds::selected(s) } else { vec![layer_param(s, p)?] };
|
||||
let targets: Vec<LayerId> = targets.into_iter().filter(|id| st.doc.layer(*id).is_some_and(|l| !st.doc.is_background(l.id))).collect();
|
||||
if targets.is_empty() {
|
||||
return Err(EngineError::Other("the Background layer can't lock transparent pixels".into()));
|
||||
}
|
||||
let lock = !targets.iter().all(|id| st.doc.layer(*id).is_some_and(|l| l.locks.transparency));
|
||||
s.edit(if lock { "Lock Transparent Pixels" } else { "Unlock Transparent Pixels" }, |doc, _| {
|
||||
for id in &targets {
|
||||
doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.locks.transparency = lock;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"locked": lock}))
|
||||
}
|
||||
|
||||
/// Is the layer at `path` shown alone: it and its enclosing groups visible, every layer outside
|
||||
/// it hidden (the layers inside a group keep their own visibility)?
|
||||
fn shown_alone(doc: &Document, path: &[usize]) -> bool {
|
||||
|
|
@ -664,15 +696,42 @@ fn rasterize_content(s: &mut Session, id: LayerId, only: Option<&str>, key: &str
|
|||
"shape" => {
|
||||
s.execute("layer.rasterize.shape", json!({"layer": id.0, "coalesce": key}))?;
|
||||
}
|
||||
_ => {
|
||||
let label = if kind == "fill" { "Rasterize Fill Content" } else { "Rasterize Smart Object" };
|
||||
"smart" => {
|
||||
s.coalesce_request = Some(key.to_string());
|
||||
let r = s.edit(label, |doc, _| {
|
||||
let r = s.edit("Rasterize Smart Object", |doc, _| {
|
||||
let snapshot = doc.clone();
|
||||
let mut snap = doc.layer(id).cloned().ok_or(EngineError::NoLayer(id))?;
|
||||
if matches!(snap.content, LayerContent::Smart(ref sm) if sm.cache.is_none()) {
|
||||
let _ = crate::smart_cmds::refresh_layer(&snapshot, &mut snap);
|
||||
}
|
||||
let fmt = snapshot.pixel_format();
|
||||
let surface = match &mut snap.content {
|
||||
LayerContent::Smart(sm) => match sm.cache.take() {
|
||||
Some(c) => c,
|
||||
None => content_pixels(&snapshot, &snap),
|
||||
},
|
||||
_ => return Err(EngineError::Other(format!("layer {} is not a smart object", id.0))),
|
||||
};
|
||||
let surface = if surface.format() == fmt { surface } else { surface.convert(fmt) };
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
l.content = LayerContent::Raster(surface);
|
||||
l.psd_blocks.retain(|(k, _)| k != b"SoLd" && k != b"SoLE" && k != b"PlLd");
|
||||
Ok(())
|
||||
});
|
||||
s.coalesce_request = None;
|
||||
r?;
|
||||
}
|
||||
_ => {
|
||||
s.coalesce_request = Some(key.to_string());
|
||||
let r = s.edit("Rasterize Fill Content", |doc, _| {
|
||||
let snap = doc.layer(id).cloned().ok_or(EngineError::NoLayer(id))?;
|
||||
let px = content_pixels(doc, &snap);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
l.content = LayerContent::Raster(px);
|
||||
l.fill_cache = None;
|
||||
// The PSD fill or placed-layer blocks no longer describe the layer: kept, they
|
||||
// would reopen it as a fill layer or smart object and drop these pixels.
|
||||
l.psd_blocks.retain(|(k, _)| !matches!(k, b"SoCo" | b"GdFl" | b"PtFl" | b"SoLd" | b"PlLd" | b"SoLE"));
|
||||
Ok(())
|
||||
});
|
||||
s.coalesce_request = None;
|
||||
|
|
@ -910,8 +969,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
},
|
||||
ungroup
|
||||
),
|
||||
spec!("layer.hideLayers", "Hide Layers", &["Layer"], Some("Cmd+,"), r##"{"layer":id?}"##, has_layer, |s, p| set_visible(s, p, false)),
|
||||
spec!(
|
||||
"layer.hideLayers",
|
||||
"Hide Layers",
|
||||
&["Layer"],
|
||||
Some("Cmd+,"),
|
||||
r##"{"layer":id?} (shows them instead when every targeted layer is already hidden, so ⌘, toggles)"##,
|
||||
has_layer,
|
||||
hide_or_show
|
||||
),
|
||||
spec!("layer.showLayers", "Show Layers", &[], None, r##"{"layer":id?}"##, has_layer, |s, p| set_visible(s, p, true)),
|
||||
spec!(
|
||||
"layer.toggleLockTransparency",
|
||||
"Lock Transparent Pixels",
|
||||
&[],
|
||||
Some("/"),
|
||||
r##"{"layer":id?} (toggles; several layers selected: locks them all unless every one is locked)"##,
|
||||
has_layer,
|
||||
toggle_lock_transparency
|
||||
),
|
||||
spec!(
|
||||
"layer.showOnly",
|
||||
"Show Only This Layer",
|
||||
|
|
@ -1490,6 +1566,48 @@ mod tests {
|
|||
assert_eq!(doc(&s).layers[0].name, "Background");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_layers_toggles_once_every_target_is_hidden() {
|
||||
let mut s = session(8);
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let b = active(&s).id;
|
||||
let visible = |s: &Session| doc(s).layer(b).unwrap().visible;
|
||||
// Select › All Layers leaves out the Background, as in Photoshop.
|
||||
let all = |s: &Session| crate::layer_multi_cmds::selected(s).iter().map(|id| doc(s).layer(*id).unwrap().visible).collect::<Vec<_>>();
|
||||
s.execute("layer.hideLayers", json!({})).unwrap();
|
||||
assert!(!visible(&s));
|
||||
s.execute("layer.hideLayers", json!({})).unwrap();
|
||||
assert!(visible(&s), "⌘, again shows it");
|
||||
// Several selected: any one still shown means hide them all; then they all come back.
|
||||
s.execute("layer.hideLayers", json!({"layer": b.0})).unwrap();
|
||||
s.execute("select.allLayers", json!({})).unwrap();
|
||||
assert!(all(&s).len() > 1);
|
||||
s.execute("layer.hideLayers", json!({})).unwrap();
|
||||
assert!(all(&s).iter().all(|v| !v));
|
||||
s.execute("layer.hideLayers", json!({})).unwrap();
|
||||
assert!(all(&s).iter().all(|v| *v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_toggles_lock_transparent_pixels_but_not_on_the_background() {
|
||||
let mut s = session(8);
|
||||
let bg = doc(&s).layers[0].id;
|
||||
assert!(doc(&s).is_background(bg));
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let top = active(&s).id;
|
||||
let locked = |s: &Session, id: LayerId| doc(s).layer(id).unwrap().locks.transparency;
|
||||
assert_eq!(s.execute("layer.toggleLockTransparency", json!({})).unwrap()["locked"], true);
|
||||
assert!(locked(&s, top));
|
||||
s.execute("layer.toggleLockTransparency", json!({})).unwrap();
|
||||
assert!(!locked(&s, top));
|
||||
s.undo();
|
||||
assert!(locked(&s, top), "one undoable step each");
|
||||
let bg_locks = doc(&s).layer(bg).unwrap().locks;
|
||||
assert!(s.execute("layer.toggleLockTransparency", json!({"layer": bg.0})).is_err());
|
||||
assert_eq!(doc(&s).layer(bg).unwrap().locks, bg_locks, "the Background is left alone");
|
||||
assert!(s.execute("layer.toggleLockTransparency", json!({"layer": 999_999})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_utilities() {
|
||||
let mut s = session(8);
|
||||
|
|
@ -1561,4 +1679,56 @@ mod tests {
|
|||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
|
||||
assert!(doc(&s).walk().iter().all(|(_, _, l)| !matches!(l.content, LayerContent::Fill(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_smart_object_layer() {
|
||||
let mut s = session(8);
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
|
||||
let px_before = pixel(&s, 5, 5);
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
assert!(matches!(active(&s).content, LayerContent::Smart(_)));
|
||||
s.execute("layer.rasterize.smartObject", json!({})).unwrap();
|
||||
assert!(matches!(active(&s).content, LayerContent::Raster(_)));
|
||||
let px_after = pixel(&s, 5, 5);
|
||||
assert_eq!(px_after, px_before);
|
||||
|
||||
// Case 2: New layer above background
|
||||
let mut s2 = session(8);
|
||||
s2.execute("layer.new.layer", json!({})).unwrap();
|
||||
s2.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
s2.execute("edit.fill", json!({"contents": "color", "color": "#00ff00"})).unwrap();
|
||||
s2.execute("select.deselect", json!({})).unwrap();
|
||||
let px_mid = pixel(&s2, 20, 20);
|
||||
assert_eq!(px_mid[1], 1.0); // green
|
||||
s2.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
assert!(matches!(active(&s2).content, LayerContent::Smart(_)));
|
||||
s2.execute("layer.rasterize.smartObject", json!({})).unwrap();
|
||||
assert!(matches!(active(&s2).content, LayerContent::Raster(_)));
|
||||
let px_after_rast = pixel(&s2, 20, 20);
|
||||
assert_eq!(px_after_rast, px_mid);
|
||||
|
||||
// Case 3: Shape layer converted to smart object then rasterized
|
||||
let mut s3 = session(8);
|
||||
s3.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 40], "fill": "#0000ff"})).unwrap();
|
||||
let px_shape = pixel(&s3, 20, 20);
|
||||
s3.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
assert!(matches!(active(&s3).content, LayerContent::Smart(_)));
|
||||
s3.execute("layer.rasterize.layer", json!({})).unwrap();
|
||||
assert!(matches!(active(&s3).content, LayerContent::Raster(_)));
|
||||
let px_after_shape = pixel(&s3, 20, 20);
|
||||
assert_eq!(px_after_shape, px_shape);
|
||||
|
||||
// Case 4: Transformed smart object
|
||||
let mut s4 = session(8);
|
||||
s4.execute("layer.new.layer", json!({})).unwrap();
|
||||
s4.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
s4.execute("edit.fill", json!({"contents": "color", "color": "#ffff00"})).unwrap();
|
||||
s4.execute("select.deselect", json!({})).unwrap();
|
||||
s4.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
// Move smart object by 10, 10
|
||||
s4.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 10, 10]})).unwrap();
|
||||
s4.execute("layer.rasterize.smartObject", json!({})).unwrap();
|
||||
assert!(matches!(active(&s4).content, LayerContent::Raster(_)));
|
||||
assert_eq!(pixel(&s4, 30, 30), vec![1.0, 1.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1035,8 +1035,10 @@ fn has_live_effects(l: &Layer) -> bool {
|
|||
l.effects.enabled && l.effects.items.iter().any(|e| e.enabled())
|
||||
}
|
||||
|
||||
/// The layer rendered alone (keeping what `keep` says), as a surface in `fmt`.
|
||||
fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool) -> Surface {
|
||||
/// The layer rendered alone (keeping what `keep` says), as a surface in `fmt`. With `apply_mask`
|
||||
/// off the layer's pixel and vector masks are left out of the render, so a caller that keeps them
|
||||
/// live on the baked raster does not mask the pixels twice.
|
||||
fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool, apply_mask: bool) -> Surface {
|
||||
let mut tmp = l.clone();
|
||||
tmp.opacity = 1.0;
|
||||
tmp.blend = photocraft_color::BlendMode::Normal;
|
||||
|
|
@ -1046,6 +1048,10 @@ fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool) -> Surfac
|
|||
tmp.effects.items.clear();
|
||||
tmp.fill_opacity = 1.0;
|
||||
}
|
||||
if !apply_mask {
|
||||
tmp.mask = None;
|
||||
tmp.vector_mask = None;
|
||||
}
|
||||
// Render over the union of the canvas and the content so off-canvas pixels survive.
|
||||
let area = l.surface().map_or(canvas, |s| s.content_bounds().union(&canvas));
|
||||
let area = if keep_effects { area.inflate(256) } else { area };
|
||||
|
|
@ -1066,12 +1072,12 @@ fn flatten_all_effects(s: &mut Session) -> Result<Value> {
|
|||
if !has_live_effects(l) || matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_)) {
|
||||
continue;
|
||||
}
|
||||
let px = bake(l, canvas, fmt, true);
|
||||
// The pixel and vector masks stay on the layer (baking must not destroy them), so the
|
||||
// pixels are baked without them and each mask then applies once to the baked raster.
|
||||
let px = bake(l, canvas, fmt, true, false);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
l.content = LayerContent::Raster(px);
|
||||
l.effects = Default::default();
|
||||
l.mask = None;
|
||||
l.vector_mask = None;
|
||||
l.fill_opacity = 1.0;
|
||||
l.fill_cache = None;
|
||||
l.psd_blocks.retain(|(k, _)| !matches!(k, b"TySh" | b"SoLd" | b"PlLd" | b"SoLE" | b"vmsk" | b"vsms" | b"vogk" | b"vscg" | b"vstk"));
|
||||
|
|
@ -1099,7 +1105,7 @@ fn flatten_all_masks(s: &mut Session) -> Result<Value> {
|
|||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let px = bake(l, canvas, fmt, false);
|
||||
let px = bake(l, canvas, fmt, false, true);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
l.content = LayerContent::Raster(px);
|
||||
l.mask = None;
|
||||
|
|
|
|||
|
|
@ -554,6 +554,118 @@ fn flatten_all_layer_effects_and_masks() {
|
|||
}
|
||||
}
|
||||
|
||||
/// Flatten All Layer Effects must bake the effect visuals without destroying the layer's masks:
|
||||
/// an enabled raster mask and a vector mask on one layer, a disabled raster mask on another
|
||||
/// (#3804). The command is a visual no-op; the masks (pixels and state) survive, including a
|
||||
/// `.pcraft` round trip.
|
||||
#[test]
|
||||
fn flatten_all_layer_effects_keeps_masks() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = session(40, 40, depth);
|
||||
// Layer 1: a red square with a green colour overlay, an enabled raster mask revealing
|
||||
// x < 20 and a vector mask revealing x 15..30.
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let id1 = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("fx", |doc, _| {
|
||||
let l = doc.layer_mut(id1).unwrap();
|
||||
l.effects.enabled = true;
|
||||
l.effects.items.push(photocraft_doc::Effect::ColorOverlay {
|
||||
common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
|
||||
color: photocraft_color::Color::rgb(0.0, 1.0, 0.0),
|
||||
});
|
||||
l.vector_mask = Some(photocraft_doc::VectorMask::new(photocraft_doc::Path::new(vec![photocraft_doc::Subpath::polygon(&[
|
||||
(15.0, 15.0),
|
||||
(30.0, 15.0),
|
||||
(30.0, 30.0),
|
||||
(15.0, 30.0),
|
||||
])])));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 40})).unwrap();
|
||||
s.execute("layer.layerMask.revealSelection", json!({})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
// Layer 2: a red square at the bottom-right with the same overlay and a *disabled* mask.
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 30, "y": 30, "width": 8, "height": 8})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let id2 = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("fx", |doc, _| {
|
||||
let l = doc.layer_mut(id2).unwrap();
|
||||
l.effects.enabled = true;
|
||||
l.effects.items.push(photocraft_doc::Effect::ColorOverlay {
|
||||
common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
|
||||
color: photocraft_color::Color::rgb(0.0, 1.0, 0.0),
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.rect", json!({"x": 34, "y": 34, "width": 2, "height": 2})).unwrap();
|
||||
s.execute("layer.layerMask.revealSelection", json!({})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.edit("disable", |doc, _| {
|
||||
if let Some(m) = doc.layer_mut(id2).and_then(|l| l.mask.as_mut()) {
|
||||
m.enabled = false;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let m1 = doc(&s).layer(id1).unwrap().mask.clone();
|
||||
let v1 = doc(&s).layer(id1).unwrap().vector_mask.clone();
|
||||
let m2 = doc(&s).layer(id2).unwrap().mask.clone();
|
||||
assert_eq!(m1.as_ref().map(|m| m.enabled), Some(true), "layer 1 raster mask is enabled");
|
||||
assert!(v1.as_ref().is_some_and(|v| v.enabled), "layer 1 vector mask is enabled");
|
||||
assert_eq!(m2.as_ref().map(|m| m.enabled), Some(false), "layer 2 raster mask is disabled");
|
||||
let before = [
|
||||
composite(&s, 17, 15), // layer 1: inside both masks
|
||||
composite(&s, 22, 15), // layer 1: raster mask hides
|
||||
composite(&s, 12, 15), // layer 1: vector mask hides
|
||||
composite(&s, 34, 34), // layer 2: the disabled mask does not hide
|
||||
];
|
||||
assert!(before[0][1] > 0.99 && before[0][0] < 0.01, "overlay renders green: {:?}", before[0]);
|
||||
// The white background shows where a mask hides the layer.
|
||||
assert!(before[1][0] > 0.99 && before[1][1] > 0.99, "the raster mask hides: {:?}", before[1]);
|
||||
assert!(before[2][0] > 0.99 && before[2][1] > 0.99, "the vector mask hides: {:?}", before[2]);
|
||||
assert!(before[3][1] > 0.99, "the disabled mask does not hide");
|
||||
let r = s.execute("file.scripts.flattenAllLayerEffects", json!({})).unwrap();
|
||||
assert_eq!(r["flattened"], 2);
|
||||
{
|
||||
let d = doc(&s);
|
||||
assert_eq!(d.layer(id1).unwrap().mask, m1, "layer 1 raster mask survives");
|
||||
assert_eq!(d.layer(id1).unwrap().vector_mask, v1, "layer 1 vector mask survives");
|
||||
assert_eq!(d.layer(id2).unwrap().mask, m2, "the disabled mask survives");
|
||||
}
|
||||
let after = [composite(&s, 17, 15), composite(&s, 22, 15), composite(&s, 12, 15), composite(&s, 34, 34)];
|
||||
for (a, b) in after.iter().zip(&before) {
|
||||
for k in 0..4 {
|
||||
assert!((a[k] - b[k]).abs() < 0.01, "flattening is a visual no-op: {after:?} vs {before:?}");
|
||||
}
|
||||
}
|
||||
// One history step: undo restores the effects, redo re-flattens, and the masks stay.
|
||||
assert!(s.undo());
|
||||
assert!(!doc(&s).layer(id1).unwrap().effects.items.is_empty());
|
||||
assert_eq!(doc(&s).layer(id1).unwrap().mask, m1);
|
||||
assert!(s.redo());
|
||||
assert_eq!(doc(&s).layer(id1).unwrap().mask, m1);
|
||||
assert_eq!(doc(&s).layer(id1).unwrap().vector_mask, v1);
|
||||
assert_eq!(doc(&s).layer(id2).unwrap().mask, m2);
|
||||
// The masks survive a `.pcraft` save and reopen.
|
||||
let dir = tmp("flatten-effects-masks");
|
||||
let path = join(&dir, "masks.pcraft");
|
||||
save_doc(doc(&s), &path, None).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let mut s2 = Session::new();
|
||||
open_bytes_as(&mut s2, "masks.pcraft", &bytes, None, None).unwrap();
|
||||
let layers: Vec<_> = doc(&s2).walk().into_iter().map(|(_, _, l)| l).collect();
|
||||
assert!(layers.iter().any(|l| l.mask == m1 && l.vector_mask == v1), "the raster and vector masks survive .pcraft");
|
||||
assert!(layers.iter().any(|l| l.mask == m2), "the disabled mask survives .pcraft");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_to_files_writes_each_visible_layer() {
|
||||
let dir = tmp("layers");
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"pattern" => {
|
||||
let pat = crate::pattern_cmds::resolve_param(s, CMD, p)?;
|
||||
let (scale, angle, _, phase) = crate::pattern_cmds::placement(p);
|
||||
let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("the pattern is empty"))?;
|
||||
let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("the pattern is empty or too large to paint"))?;
|
||||
// Photoshop's Fill uses the canvas origin.
|
||||
let place = photocraft_compose::pattern::Placement::new(Rect::EMPTY, false, phase, scale, angle);
|
||||
out["pattern"] = json!(pat.id);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! Photoshop's fill keys (#249): ⌥⌫ fills the selection (or the whole layer) with the foreground
|
||||
//! colour, ⌘⌫ with the background colour, and with ⇧ added they preserve transparency. Photoshop
|
||||
//! colour, ⌘⌫ with the background colour, and with ⇧ added they preserve transparency. ⌘⌥⌫ fills
|
||||
//! from history: the History Brush's source state (the oldest state still held). Photoshop
|
||||
//! has no menu item for them; here they are ordinary commands with default shortcuts, so they
|
||||
//! appear in Edit › Keyboard Shortcuts (Tools) and can be rebound, recorded and driven by agents.
|
||||
//!
|
||||
|
|
@ -12,7 +13,7 @@ use crate::commands::CommandSpec;
|
|||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The fill-key command ids (routed to the Channels panel target like `edit.fill`).
|
||||
pub const IDS: [&str; 4] = ["edit.fillForeground", "edit.fillBackground", "edit.fillForegroundPreserve", "edit.fillBackgroundPreserve"];
|
||||
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}?}"##;
|
||||
|
||||
|
|
@ -39,6 +40,22 @@ fn fill(s: &mut Session, p: &Value, cmd: &str, background: bool, preserve: bool)
|
|||
(edit_fill.run)(s, &Value::Object(q))
|
||||
}
|
||||
|
||||
/// ⌘⌥⌫: `edit.fill` from history, the History Brush's source unless `state` names another.
|
||||
fn fill_history(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "edit.fillHistory";
|
||||
let mut q: Map<String, Value> = match p {
|
||||
Value::Object(m) => m.clone(),
|
||||
Value::Null => Map::new(),
|
||||
_ => return Err(EngineError::BadParams { cmd: CMD.into(), msg: "expected an object".into() }),
|
||||
};
|
||||
for k in ["color", "contents", "pattern"] {
|
||||
q.remove(k);
|
||||
}
|
||||
q.insert("contents".into(), json!("history"));
|
||||
let edit_fill = crate::commands::find("edit.fill").ok_or_else(|| EngineError::Other("Fill is unavailable".into()))?;
|
||||
(edit_fill.run)(s, &Value::Object(q))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
|
|
@ -81,6 +98,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
run: |s, p| fill(s, p, "edit.fillBackgroundPreserve", true, true),
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "edit.fillHistory",
|
||||
label: "Fill from History",
|
||||
menu: &[],
|
||||
shortcut: Some("Cmd+Alt+Backspace"),
|
||||
params: r##"{"state":n? (default: the History Brush's source, the oldest state still held),"layer":id?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?,"preserveTransparency":bool?}"##,
|
||||
enabled,
|
||||
run: fill_history,
|
||||
journal: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +160,27 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_from_history_restores_the_history_brush_source_in_the_selection() {
|
||||
let mut s = session();
|
||||
assert_eq!(crate::commands::find("edit.fillHistory").and_then(|c| c.shortcut), Some("Cmd+Alt+Backspace"));
|
||||
s.execute("edit.fillForeground", json!({})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 6})).unwrap();
|
||||
s.execute("edit.fillHistory", json!({})).unwrap();
|
||||
// The oldest state (the new, transparent layer) comes back inside the selection only.
|
||||
assert_eq!(px(&s, 1, 1)[3], 0.0);
|
||||
assert_eq!(px(&s, 6, 1), [1.0, 0.0, 0.0, 1.0]);
|
||||
// A named state, and refused ones change nothing.
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
for bad in [json!({"state": 99}), json!({"state": "x"}), json!(7)] {
|
||||
assert!(s.execute("edit.fillHistory", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
s.execute("edit.fillHistory", json!({"state": 1})).unwrap();
|
||||
assert_eq!(px(&s, 1, 1), [1.0, 0.0, 0.0, 1.0], "state 1 is after the foreground fill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_fills_only_the_selection() {
|
||||
let mut s = session();
|
||||
|
|
|
|||
|
|
@ -185,7 +185,10 @@ pub fn apply_filter_to_surface(
|
|||
if id == crate::plugin_cmds::RUN {
|
||||
return crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas);
|
||||
}
|
||||
let fp = params_for(id, params)?;
|
||||
let mut fp = params_for(id, params)?;
|
||||
// A map that can no longer be read (moved or deleted since) leaves the pixels alone, like an
|
||||
// unknown filter; the command itself already refused an unreadable map when it was added.
|
||||
crate::filters_ext::resolve_map_path(&mut fp, params).ok()?;
|
||||
let sel_bounds = selection.map(photocraft_raster::Surface::content_bounds);
|
||||
let content = surf.content_bounds();
|
||||
let area = algo::output_area(&fp, content, bounds, sel_bounds);
|
||||
|
|
|
|||
|
|
@ -153,7 +153,8 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
|
|||
vertical: f(p, "vertical", 10.0).clamp(-999.0, 999.0),
|
||||
stretch: s(p, "fit", "stretch") != "tile",
|
||||
undefined: undefined(p, "repeat"),
|
||||
map: None,
|
||||
// A Displace imported from PSD carries its map; `mapPath` / `mapDocument` resolve later.
|
||||
map: photocraft_io::smart_map::displace_embedded_map(p),
|
||||
},
|
||||
"filter.distort.shear" => FilterParams::Shear {
|
||||
points: list::<[f32; 2]>(p, "points").unwrap_or_else(|| {
|
||||
|
|
@ -435,10 +436,8 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<(
|
|||
return Ok(());
|
||||
}
|
||||
let layer = p.get("mapLayer").and_then(Value::as_u64);
|
||||
let img = if let Some(path) = p.get("mapPath").and_then(Value::as_str).filter(|p| !p.is_empty()) {
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read displacement map `{path}`: {e}")))?;
|
||||
let imported = crate::file_cmds::import(path, &bytes).map_err(|e| EngineError::Other(format!("cannot open displacement map `{path}`: {e}")))?;
|
||||
map_from_document(&imported, layer)
|
||||
let img = if let Some(path) = map_path(p) {
|
||||
map_from_path(path, layer)?
|
||||
} else if let Some(d) = p.get("mapDocument") {
|
||||
let docs = s.documents();
|
||||
let found = match d {
|
||||
|
|
@ -454,6 +453,30 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn map_path(p: &Value) -> Option<&str> {
|
||||
p.get("mapPath").and_then(Value::as_str).filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
/// Displace's map read from a file (flattened, or its `mapLayer`).
|
||||
fn map_from_path(path: &str, layer: Option<u64>) -> Result<Option<algo::Image>> {
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read displacement map `{path}`: {e}")))?;
|
||||
let imported = crate::file_cmds::import(path, &bytes).map_err(|e| EngineError::Other(format!("cannot open displacement map `{path}`: {e}")))?;
|
||||
Ok(map_from_document(&imported, layer))
|
||||
}
|
||||
|
||||
/// The session-free part of [`resolve`]: Displace's `mapPath`. Smart filters record params, not
|
||||
/// the map, so their re-render reads the map again here; without it the filter passed the pixels
|
||||
/// through. `mapDocument` needs the session and is left unresolved.
|
||||
pub(crate) fn resolve_map_path(fp: &mut FilterParams, p: &Value) -> Result<()> {
|
||||
if let FilterParams::Displace { map, .. } = fp
|
||||
&& map.is_none()
|
||||
&& let Some(path) = map_path(p)
|
||||
{
|
||||
*map = map_from_path(path, p.get("mapLayer").and_then(Value::as_u64))?.map(Arc::new);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves inputs read from the target layer itself: the layer mask as Lens Blur depth.
|
||||
pub(crate) fn resolve_in_layer(fp: &mut FilterParams, layer: &Layer, bounds: Rect) {
|
||||
if let FilterParams::LensBlur { depth: DepthSource::LayerMask, depth_map, .. } = fp
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
//! #3778: Flatten Image, Merge Visible and Merge Down keep a CMYK document's ink values instead
|
||||
//! of round-tripping them through RGB.
|
||||
|
||||
use photocraft_cms::synth::{CmykParams, cmyk_profile};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, Size};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
const W: i32 = 16;
|
||||
const H: i32 = 8;
|
||||
|
||||
fn canvas() -> Rect {
|
||||
Rect::new(0, 0, W, H)
|
||||
}
|
||||
|
||||
/// Ink values that vary per pixel and per channel, away from the 8-bit grid.
|
||||
fn ink(x: i32, y: i32) -> [f32; 4] {
|
||||
let f = |k: i32| ((x * 7 + y * 13 + k * 29) % 97) as f32 / 96.0 * 0.9 + 0.0371;
|
||||
[f(0), f(1), f(2), f(3)]
|
||||
}
|
||||
|
||||
/// A 16-bit CMYK document tagged with a synthetic profile (not the built-in coated CMYK), holding
|
||||
/// just an opaque Background of varied ink.
|
||||
fn cmyk_doc() -> Document {
|
||||
let mut d = Document::new("cmyk", Size::new(W as u32, H as u32), ColorMode::Cmyk, SampleType::U16);
|
||||
let profile =
|
||||
cmyk_profile(&CmykParams { description: "Flatten Test CMYK".into(), tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, grid_b2a: 9, ..Default::default() });
|
||||
d.icc_profile = Some(profile.to_bytes());
|
||||
let mut bg = Surface::new(PixelFormat::new(ColorMode::Cmyk, SampleType::U16, false));
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
bg.write_pixel(x, y, &ink(x, y));
|
||||
}
|
||||
}
|
||||
let mut layer = Layer::new("Background", LayerContent::Raster(bg));
|
||||
layer.locks.transparency = true;
|
||||
layer.locks.position = true;
|
||||
d.layers.push(layer);
|
||||
d
|
||||
}
|
||||
|
||||
/// A layer above the Background: solid ink on the left half, half-transparent ink on a strip,
|
||||
/// nothing on the rest.
|
||||
fn upper_layer() -> Layer {
|
||||
let mut s = Surface::new(PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true));
|
||||
s.fill_rect(Rect::new(0, 0, W / 2, H), &[0.8123, 0.1234, 0.4567, 0.0789, 1.0]);
|
||||
s.fill_rect(Rect::new(W / 2, 0, W / 2 + 2, H), &[0.25, 0.5, 0.75, 0.1, 0.5]);
|
||||
Layer::new("Layer 1", LayerContent::Raster(s))
|
||||
}
|
||||
|
||||
fn session(doc: Document) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc, None);
|
||||
s
|
||||
}
|
||||
|
||||
/// The bottom layer's ink values (C, M, Y, K per pixel, without any alpha channel).
|
||||
fn pixels(s: &Session) -> Vec<f32> {
|
||||
let surf = s.active().unwrap().doc.layers[0].surface().unwrap();
|
||||
let n = surf.channels();
|
||||
surf.read_region(canvas()).chunks_exact(n).flat_map(|p| p[..4].to_vec()).collect()
|
||||
}
|
||||
|
||||
fn bottom_format(s: &Session) -> PixelFormat {
|
||||
s.active().unwrap().doc.layers[0].surface().unwrap().format()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flattening_a_lone_cmyk_background_keeps_its_pixels() {
|
||||
let doc = cmyk_doc();
|
||||
let before = doc.layers[0].surface().unwrap().read_region(canvas());
|
||||
let mut s = session(doc);
|
||||
s.execute("layer.flattenImage", json!({})).unwrap();
|
||||
let fmt = bottom_format(&s);
|
||||
assert_eq!((fmt.mode, fmt.sample), (ColorMode::Cmyk, SampleType::U16), "16-bit CMYK stays 16-bit CMYK");
|
||||
let moved = pixels(&s).iter().zip(&before).filter(|(a, b)| a != b).count();
|
||||
assert_eq!(moved, 0, "a single Background must flatten to the same ink values");
|
||||
}
|
||||
|
||||
/// What a Normal 100 % layer over the Background gives, worked out in ink values: the solid part
|
||||
/// replaces the ink, the half-transparent strip mixes half and half, the rest shows the Background.
|
||||
fn expected_two_layers() -> Vec<f32> {
|
||||
let mut out = Vec::new();
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let below = ink(x, y);
|
||||
let px = if x < W / 2 {
|
||||
[0.8123, 0.1234, 0.4567, 0.0789]
|
||||
} else if x < W / 2 + 2 {
|
||||
let top = [0.25, 0.5, 0.75, 0.1];
|
||||
[0, 1, 2, 3].map(|c| top[c] * 0.5 + below[c] * 0.5)
|
||||
} else {
|
||||
below
|
||||
};
|
||||
out.extend_from_slice(&px);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn assert_close(got: &[f32], want: &[f32], what: &str) {
|
||||
assert_eq!(got.len(), want.len(), "{what}: pixel count");
|
||||
// One 16-bit code (the stored value is quantised to 1/65535).
|
||||
let worst = got.iter().zip(want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(worst <= 1.0 / 65535.0 + 1e-6, "{what}: ink values moved by up to {worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flattening_normal_cmyk_layers_composites_in_ink_values() {
|
||||
let mut doc = cmyk_doc();
|
||||
doc.layers.push(upper_layer());
|
||||
let mut s = session(doc);
|
||||
s.execute("layer.flattenImage", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), 1);
|
||||
assert_eq!(bottom_format(&s).sample, SampleType::U16);
|
||||
assert_close(&pixels(&s), &expected_two_layers(), "Flatten Image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_visible_and_merge_down_keep_cmyk_ink_values() {
|
||||
for cmd in ["layer.mergeVisible", "layer.mergeDown"] {
|
||||
let mut doc = cmyk_doc();
|
||||
let upper = upper_layer();
|
||||
let id = upper.id;
|
||||
doc.layers.push(upper);
|
||||
let mut s = session(doc);
|
||||
s.execute(cmd, json!({"layer": id.0})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), 1, "{cmd}");
|
||||
assert_eq!(bottom_format(&s).sample, SampleType::U16, "{cmd}");
|
||||
assert_close(&pixels(&s), &expected_two_layers(), cmd);
|
||||
}
|
||||
}
|
||||
|
||||
/// A stack the ink-value path can't take (a Multiply layer) still goes through the compositor,
|
||||
/// but separates back through the document's profile, the one it read the ink through, so the
|
||||
/// round trip lands near the original ink instead of on the built-in profile's separation.
|
||||
#[test]
|
||||
fn compositor_flatten_separates_through_the_documents_profile() {
|
||||
let mut doc = cmyk_doc();
|
||||
// Light inks, inside the printable gamut, so a separation can give them back.
|
||||
let bg = doc.layers[0].surface_mut().unwrap();
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
bg.write_pixel(x, y, &ink(x, y).map(|v| v * 0.4));
|
||||
}
|
||||
}
|
||||
let before = doc.layers[0].surface().unwrap().read_region(canvas());
|
||||
let mut empty = Layer::new("Layer 1", LayerContent::Raster(Surface::new(PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true))));
|
||||
empty.blend = photocraft_color::blend::BlendMode::Multiply;
|
||||
doc.layers.push(empty);
|
||||
let mut s = session(doc);
|
||||
s.execute("layer.flattenImage", json!({})).unwrap();
|
||||
// Compare what the inks print as (a CMYK colour has many separations): RGB through the profile.
|
||||
let d = &s.active().unwrap().doc;
|
||||
let space = photocraft_compose::cmyk_space(d);
|
||||
let shown = |v: &[f32]| -> Vec<[f32; 3]> {
|
||||
photocraft_color::convert::with_cmyk_space(space.as_ref(), || v.as_chunks::<4>().0.iter().map(|p| photocraft_color::convert::cmyk_to_rgb(*p)).collect())
|
||||
};
|
||||
let (a, b) = (shown(&pixels(&s)), shown(&before));
|
||||
let diffs: Vec<f32> = a.iter().zip(&b).flat_map(|(p, q)| (0..3).map(move |c| (p[c] - q[c]).abs())).collect();
|
||||
let mean = diffs.iter().sum::<f32>() / diffs.len() as f32;
|
||||
// The synthetic profile's small tables don't round-trip exactly; the built-in profile's
|
||||
// separation lands about 0.08 off on average.
|
||||
assert!(mean < 0.03, "flattening shifted the printed colour by {mean} on average");
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ impl CutParts {
|
|||
// A mask has no alpha: it keeps its format, and the cut leaves background colour.
|
||||
let mut rest = if mask { surf.clone() } else { full.clone() };
|
||||
let mut piece = Surface::new(with_alpha);
|
||||
let fill = !copy && (mask || crate::extra_cmds::is_background(l));
|
||||
let fill = !copy && (mask || doc.is_background(layer));
|
||||
// A mask's untouched pixels (its default, e.g. white) move too, so take the whole canvas.
|
||||
let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { surf.content_bounds() });
|
||||
if !b.is_empty() {
|
||||
|
|
@ -165,7 +165,7 @@ fn can_float(s: &Session) -> std::result::Result<(), String> {
|
|||
/// floating? The Background is position-locked, but its selected pixels move (Photoshop).
|
||||
pub fn locked_for_float(doc: &Document, l: &photocraft_doc::Layer) -> bool {
|
||||
let locks = doc.effective_locks(l.id);
|
||||
locks.all || (locks.position && !crate::extra_cmds::is_background(l))
|
||||
locks.all || (locks.position && !doc.is_background(l.id))
|
||||
}
|
||||
|
||||
fn float(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ fn new_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let gid = match content {
|
||||
Some(id) => {
|
||||
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if !matches!(l.content, LayerContent::Raster(_)) || crate::extra_cmds::is_background(l) {
|
||||
if !matches!(l.content, LayerContent::Raster(_)) || doc.is_background(id) {
|
||||
return Err(bad("only a pixel layer (not the Background) can go in a frame".into()));
|
||||
}
|
||||
let gid = doc.insert_above(Some(id), Layer::group(name, vec![]));
|
||||
|
|
|
|||
|
|
@ -199,8 +199,39 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
|||
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
|
||||
}
|
||||
|
||||
/// `history.undo` / `history.redo` enable predicates, matching `edit.undo` / `edit.redo`.
|
||||
fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().filter(|d| d.history.can_undo()).map(|_| ()).ok_or_else(|| "nothing to undo".into())
|
||||
}
|
||||
|
||||
fn can_redo(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().filter(|d| d.history.can_redo()).map(|_| ()).ok_or_else(|| "nothing to redo".into())
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
// `history.*` aliases of Edit › Undo / Redo, so a client reaching for the History namespace
|
||||
// finds them (`edit.undo` / `edit.redo` remain the menu items and own the shortcuts, #3675).
|
||||
CommandSpec {
|
||||
id: "history.undo",
|
||||
label: "Undo",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{} (the same action as `edit.undo`)",
|
||||
enabled: can_undo,
|
||||
run: |s, _| Ok(json!(s.undo())),
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "history.redo",
|
||||
label: "Redo",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{} (the same action as `edit.redo`)",
|
||||
enabled: can_redo,
|
||||
run: |s, _| Ok(json!(s.redo())),
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "history.deleteState",
|
||||
label: "Delete State",
|
||||
|
|
@ -282,6 +313,16 @@ mod tests {
|
|||
s.active().unwrap().history.entries()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_undo_and_redo_alias_edit_undo_and_redo() {
|
||||
let mut s = session();
|
||||
let n = s.active().unwrap().history.past_len();
|
||||
s.execute("history.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), n - 1, "history.undo steps back like edit.undo");
|
||||
s.execute("history.redo", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), n, "history.redo steps forward like edit.redo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_current_state_steps_back_and_drops_it() {
|
||||
let mut s = session();
|
||||
|
|
|
|||
|
|
@ -99,7 +99,9 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scale_effects(fx: &mut Effects, k: f32) {
|
||||
/// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style ›
|
||||
/// Scale Effects, and previews that draw a style at a reduced resolution).
|
||||
pub fn scale_effects(fx: &mut Effects, k: f32) {
|
||||
for e in &mut fx.items {
|
||||
match e {
|
||||
Effect::DropShadow(s) | Effect::InnerShadow(s) => {
|
||||
|
|
@ -298,7 +300,7 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi
|
|||
// A Background cannot hold transparent pixels: like an opaque Canvas Size extension, the area
|
||||
// an extending Crop adds is filled with the extension (background) colour.
|
||||
if extends
|
||||
&& let Some(bg) = doc.layers.first_mut().filter(|l| crate::extra_cmds::is_background(l))
|
||||
&& let Some(bg) = doc.background_mut()
|
||||
&& let LayerContent::Raster(surf) = &mut bg.content
|
||||
{
|
||||
let old = old_canvas.translate(dx, dy);
|
||||
|
|
@ -379,16 +381,13 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
// A shrink or no-op resize must not silently convert the Background.
|
||||
if ext[3] == 0.0
|
||||
&& (nw > ow as u32 || nh > oh as u32)
|
||||
&& let Some(bg) = doc.layers.first_mut()
|
||||
&& crate::extra_cmds::is_background(bg)
|
||||
&& let Some(bg) = doc.background_mut()
|
||||
{
|
||||
crate::extra_cmds::unlock_background(bg);
|
||||
}
|
||||
// The locked Background layer is extended with the extension colour.
|
||||
if ext[3] > 0.0
|
||||
&& let Some(bg) = doc.layers.first_mut()
|
||||
&& bg.name == "Background"
|
||||
&& bg.locks.transparency
|
||||
&& let Some(bg) = doc.background_mut()
|
||||
&& let LayerContent::Raster(surf) = &mut bg.content
|
||||
{
|
||||
let fmt = surf.format();
|
||||
|
|
@ -651,7 +650,20 @@ fn convert_depth(s: &mut Session, depth: SampleType) -> Result<Value> {
|
|||
return Ok(Value::Null);
|
||||
}
|
||||
s.edit("Bit Depth", |doc, _| {
|
||||
// 32-bit documents hold linear light (as Photoshop's do: grey 128 becomes 0.2159, measured
|
||||
// in Photoshop 27.11), so the colour samples cross the document's tone curve when the
|
||||
// depth crosses 32 bits: decoded after widening, encoded before narrowing (so no linear
|
||||
// value is quantised to 8 or 16 bits). Masks, alpha and channels are not colour.
|
||||
let crosses = (current == SampleType::F32) != (depth == SampleType::F32) && matches!(doc.mode, ColorMode::Rgb | ColorMode::Grayscale);
|
||||
let to_linear = depth == SampleType::F32;
|
||||
let transfer = photocraft_compose::adjust::Transfer::for_document(doc.mode, if to_linear { current } else { depth });
|
||||
if crosses && !to_linear {
|
||||
for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.encode(v).clamp(0.0, 1.0)));
|
||||
}
|
||||
convert_layers_depth(&mut doc.layers, depth);
|
||||
if crosses && to_linear {
|
||||
for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.decode(v)));
|
||||
}
|
||||
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
|
||||
let f = ch.surface.format().with_sample(depth);
|
||||
ch.surface = ch.surface.convert(f);
|
||||
|
|
@ -1338,6 +1350,31 @@ mod tests {
|
|||
assert_eq!(doc(&s).size, Size::new(10, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thirty_two_bit_holds_linear_light() {
|
||||
// Photoshop 27.11: an 8-bit sRGB grey 128 becomes 0.2159 in 32 bits (and 32 → 0.0144),
|
||||
// and comes back unchanged; alpha keeps its value.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 4, "height": 1, "background": "white"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.edit("paint", |doc, active| {
|
||||
let l = doc.layer_mut(active.unwrap()).unwrap();
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 1), &[128.0 / 255.0, 32.0 / 255.0, 1.0, 0.5]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("image.mode.bits32", json!({})).unwrap();
|
||||
let px = doc(&s).layers[1].surface().unwrap().pixel(0, 0);
|
||||
for (got, want) in px.iter().zip([0.2159, 0.0144, 1.0, 128.0 / 255.0]) {
|
||||
assert!((got - want).abs() < 2e-4, "{px:?}");
|
||||
}
|
||||
s.execute("image.mode.bits8", json!({})).unwrap();
|
||||
let px = doc(&s).layers[1].surface().unwrap().pixel(0, 0);
|
||||
for (got, want) in px.iter().zip([128.0 / 255.0, 32.0 / 255.0, 1.0, 128.0 / 255.0]) {
|
||||
assert!((got - want).abs() < 0.6 / 255.0, "{px:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_and_depth_conversions() {
|
||||
let mut s = session();
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let src_layer = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?;
|
||||
let mut l = src_layer.duplicate();
|
||||
// A dragged Background arrives as an ordinary layer (the destination keeps its own).
|
||||
if crate::extra_cmds::is_background(src_layer) && sdoc.layers.first().is_some_and(|b| b.id == *id) {
|
||||
if sdoc.is_background(*id) {
|
||||
l.name = ddoc.next_layer_name("Layer");
|
||||
l.locks = Default::default();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,11 @@ fn edits_preserve_mask_pixels_and_layer_at_all_depths_with_undo_redo() {
|
|||
assert_eq!(*s.active().unwrap().doc, expected, "only density and feather change");
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(background));
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), count + 1);
|
||||
assert!(s.active().unwrap().last_damage.is_none(), "properties invalidate the composite");
|
||||
// Properties invalidate the composite as far as their undo would (the masked layer is
|
||||
// empty here; the adjustment reaches everything).
|
||||
let damage = s.active().unwrap().last_damage;
|
||||
assert_eq!(damage, crate::layer_multi_cmds::step_damage(&before, &s.active().unwrap().doc));
|
||||
assert_eq!(damage.is_none(), adjustment);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(*s.active().unwrap().doc, *before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ pub(crate) fn mask_targets(s: &Session, p: &serde_json::Value) -> Result<Vec<Lay
|
|||
return Ok(ids);
|
||||
}
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| l.mask.is_none() && !crate::extra_cmds::is_background(l))).collect();
|
||||
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| l.mask.is_none() && !d.doc.is_background(l.id))).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("none of the selected layers can take a layer mask".into()));
|
||||
}
|
||||
|
|
@ -163,13 +163,9 @@ pub fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"selected": selected(s).iter().map(|l| l.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub(crate) fn is_locked_background(l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_))
|
||||
}
|
||||
|
||||
fn select_all_layers(s: &mut Session) -> Result<Value> {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| !is_locked_background(l)).map(|(_, _, l)| l.id).collect();
|
||||
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| !d.doc.is_background(l.id)).map(|(_, _, l)| l.id).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("there are no layers to select".into()));
|
||||
}
|
||||
|
|
@ -671,35 +667,31 @@ pub fn same_pixels(a: &Document, b: &Document) -> bool {
|
|||
}
|
||||
|
||||
/// Pixels a single history step can have changed between `a` and `b` (the documents on either
|
||||
/// side of an undo or redo): the union of the change bounds of every layer whose pixels
|
||||
/// differ, so undoing a local edit recomposites only that area instead of the whole canvas.
|
||||
/// `None` (composite everything, as before) when the step touched anything the layer walk
|
||||
/// cannot bound: document-level fields the compositor reads (canvas size, mode, depth,
|
||||
/// profile, global light, patterns, channels, palettes), the layer structure (order,
|
||||
/// additions, removals, kind changes; adding or removing a layer that draws nothing, see
|
||||
/// [`inert_at`], is no change), layer properties that reach outside their own bounds
|
||||
/// (clipping, excluded channels — what `layer.setProps` already reports as unbounded), and
|
||||
/// video layers.
|
||||
pub fn step_damage(a: &Document, b: &Document) -> Option<Rect> {
|
||||
damage_between(a, b, false)
|
||||
}
|
||||
|
||||
/// [`step_damage`] for an edit from `before` to `after` that may also have added layers which
|
||||
/// change pixels only within their own bounds (see [`added_bounds`]), as Duplicate Layer does:
|
||||
/// each contributes its change bounds instead of making the whole canvas recomposite. Inserting a
|
||||
/// copy of a 12 MP layer used to recomposite every layer of the document (#2883, P8/P9 in
|
||||
/// side of an edit, its undo or its redo): the union of the change bounds of every layer whose
|
||||
/// pixels differ, so a local edit recomposites only that area instead of the whole canvas. A
|
||||
/// layer only one side has (a duplicate, a paste, a deleted layer) counts where it draws (see
|
||||
/// [`one_sided_bounds`]); one that draws nothing ([`inert_at`]) changes nothing. `None`
|
||||
/// (composite everything) when the step touched anything the layer walk cannot bound:
|
||||
/// document-level fields the compositor reads (canvas size, mode, depth, profile, global light,
|
||||
/// patterns, channels, palettes), layer order and kind changes, layers added or removed that
|
||||
/// reach outside their own bounds, layer properties that do (clipping, excluded channels —
|
||||
/// what `layer.setProps` already reports as unbounded), and video layers. Inserting a copy of a
|
||||
/// 12 MP layer used to recomposite every layer of the document (#2883, P8/P9 in
|
||||
/// `perf/budgets.toml`).
|
||||
pub(crate) fn insert_damage(before: &Document, after: &Document) -> Option<Rect> {
|
||||
damage_between(before, after, true)
|
||||
}
|
||||
|
||||
fn damage_between(a: &Document, b: &Document, bounded_additions: bool) -> Option<Rect> {
|
||||
pub fn step_damage(a: &Document, b: &Document) -> Option<Rect> {
|
||||
if !doc_pixels_equal(a, b) {
|
||||
return None;
|
||||
}
|
||||
fn walk(xs: &[Layer], ys: &[Layer], a: &Document, b: &Document, added: bool, out: &mut Rect) -> bool {
|
||||
// Pair the layers by id, stepping over layers only one side has when they draw nothing
|
||||
// (a new empty layer or group, #1771): adding or deleting one changes no pixels.
|
||||
fn walk(xs: &[Layer], ys: &[Layer], a: &Document, b: &Document, out: &mut Rect) -> bool {
|
||||
// Pair the layers by id, stepping over layers only one side has: one that draws nothing
|
||||
// changes no pixels (a new empty layer or group, #1771), any other only where it draws.
|
||||
let one_sided = |layers: &[Layer], k: usize, canvas: Rect, out: &mut Rect| {
|
||||
let Some(r) = one_sided_bounds(layers, k, canvas) else { return false };
|
||||
if !r.is_empty() {
|
||||
*out = if out.is_empty() { r } else { out.union(&r) };
|
||||
}
|
||||
true
|
||||
};
|
||||
let (mut i, mut j) = (0, 0);
|
||||
loop {
|
||||
let (x, y) = match (xs.get(i), ys.get(j)) {
|
||||
|
|
@ -709,19 +701,25 @@ fn damage_between(a: &Document, b: &Document, bounded_additions: bool) -> Option
|
|||
j += 1;
|
||||
continue;
|
||||
}
|
||||
_ if added && ys.get(j).is_some_and(|y| xs.iter().all(|x| x.id != y.id)) => {
|
||||
// A layer only `b` has: bounded when it is self-contained.
|
||||
let Some(r) = added_bounds(ys, j, b.bounds()) else { return false };
|
||||
if !r.is_empty() {
|
||||
*out = if out.is_empty() { r } else { out.union(&r) };
|
||||
}
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
_ if inert_at(xs, i) => {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
(_, Some(y)) if xs.iter().all(|x| x.id != y.id) => {
|
||||
if !one_sided(ys, j, b.bounds(), out) {
|
||||
return false;
|
||||
}
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
(Some(x), _) if ys.iter().all(|y| y.id != x.id) => {
|
||||
if !one_sided(xs, i, a.bounds(), out) {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Both sides have the layer, in another order.
|
||||
_ => return false,
|
||||
};
|
||||
i += 1;
|
||||
|
|
@ -743,7 +741,7 @@ fn damage_between(a: &Document, b: &Document, bounded_additions: bool) -> Option
|
|||
}
|
||||
match (x.children(), y.children()) {
|
||||
(Some(xc), Some(yc)) => {
|
||||
if !walk(xc, yc, a, b, added, out) {
|
||||
if !walk(xc, yc, a, b, out) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -753,15 +751,15 @@ fn damage_between(a: &Document, b: &Document, bounded_additions: bool) -> Option
|
|||
}
|
||||
}
|
||||
let mut out = Rect::EMPTY;
|
||||
if walk(&a.layers, &b.layers, a, b, bounded_additions, &mut out) { Some(out) } else { None }
|
||||
if walk(&a.layers, &b.layers, a, b, &mut out) { Some(out) } else { None }
|
||||
}
|
||||
|
||||
/// The pixels adding `layers[k]` can change, when that is bounded: its change bounds
|
||||
/// The pixels adding or removing `layers[k]` can change, when that is bounded: its change bounds
|
||||
/// (`photocraft_compose::change_bounds`: not for an adjustment, or a pass-through group holding
|
||||
/// one), provided it doesn't become the clipping base of clipped layers above it, and neither it
|
||||
/// nor anything inside it is a video layer or excludes channels (both reach outside the layer's
|
||||
/// bounds, see [`step_damage`]).
|
||||
fn added_bounds(layers: &[Layer], k: usize, canvas: Rect) -> Option<Rect> {
|
||||
/// one), provided it isn't the clipping base of clipped layers above it (they would change base),
|
||||
/// and neither it nor anything inside it is a video layer or excludes channels (both reach
|
||||
/// outside the layer's bounds, see [`step_damage`]).
|
||||
fn one_sided_bounds(layers: &[Layer], k: usize, canvas: Rect) -> Option<Rect> {
|
||||
fn contained(l: &Layer, depth: usize) -> bool {
|
||||
depth < 256 && l.video.is_none() && l.excluded_channels == 0 && l.children().is_none_or(|c| c.iter().all(|c| contained(c, depth + 1)))
|
||||
}
|
||||
|
|
@ -808,11 +806,12 @@ pub(crate) fn note_inert_insert(s: &mut Session, id: LayerId) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Report the pixels an edit from `before` that added layers changed ([`insert_damage`]), so
|
||||
/// the canvas recomposites only where the new layers draw instead of the whole document.
|
||||
/// Report the pixels an edit from `before` that added layers changed ([`step_damage`]), for
|
||||
/// commands made of more than one edit, so the canvas recomposites only where the new layers
|
||||
/// draw instead of the whole document.
|
||||
pub(crate) fn note_insert(s: &mut Session, before: &Document) {
|
||||
let Some(st) = s.active_mut() else { return };
|
||||
st.last_damage = insert_damage(before, &st.doc);
|
||||
st.last_damage = step_damage(before, &st.doc);
|
||||
}
|
||||
|
||||
/// Content bounds used by Align/Distribute: the layer's pixels (type and shape layers use their
|
||||
|
|
@ -1218,6 +1217,12 @@ pub fn group_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn merge_layers(s: &mut Session) -> Result<Value> {
|
||||
let sel = selected(s);
|
||||
if sel.len() < 2 {
|
||||
// ⌘E on a lone group is Photoshop's Merge Group (#3832); on a layer, Merge Down.
|
||||
if let Some(&id) = sel.first()
|
||||
&& s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Group(_)))
|
||||
{
|
||||
return s.execute("layer.mergeGroup", json!({ "layer": id.0 }));
|
||||
}
|
||||
return s.execute("layer.mergeDown", json!({}));
|
||||
}
|
||||
let mid = s.edit("Merge Layers", |doc, active| {
|
||||
|
|
@ -1592,7 +1597,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn new_layers_that_change_pixels_still_recomposite_everything() {
|
||||
fn new_layers_that_change_clipping_recomposite_everything() {
|
||||
let mut s = session(8);
|
||||
let base = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||||
let clipped = rect_layer(&mut s, Rect::new(0, 0, 100, 80));
|
||||
|
|
@ -1616,21 +1621,33 @@ mod tests {
|
|||
assert_eq!(step_damage(&before, &split), None);
|
||||
assert_eq!(step_damage(&split, &before), None);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// A step that adds a layer with pixels (Layer via Copy, paste…) is not inert either.
|
||||
// A step that adds a layer with pixels (Layer via Copy, paste…) changes where it draws.
|
||||
let before = doc(&s).clone();
|
||||
let mut after = before.clone();
|
||||
let mut l = Layer::raster("painted", after.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(1, 1, 2, 2), &[0.0, 1.0, 0.0, 1.0]);
|
||||
after.insert_above(None, l);
|
||||
assert_eq!(step_damage(&before, &after), None);
|
||||
assert_eq!(step_damage(&after, &before), None);
|
||||
// Nor, conservatively, an empty layer in another blend mode.
|
||||
assert_eq!(step_damage(&before, &after), Some(Rect::new(1, 1, 2, 2)));
|
||||
assert_eq!(step_damage(&after, &before), Some(Rect::new(1, 1, 2, 2)));
|
||||
// Unless it becomes the base of clipped layers above it.
|
||||
let mut based = before.clone();
|
||||
let mut l = Layer::raster("base", based.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(1, 1, 2, 2), &[0.0, 1.0, 0.0, 1.0]);
|
||||
let under = based.layers.iter().position(|l| l.id == clipped).unwrap();
|
||||
based.layers.insert(under, l);
|
||||
assert_eq!(step_damage(&before, &based), None);
|
||||
assert_eq!(step_damage(&based, &before), None);
|
||||
// An adjustment reaches every pixel below it.
|
||||
let mut adjusted = before.clone();
|
||||
adjusted.insert_above(None, Layer::new("inv", LayerContent::Adjustment(photocraft_doc::Adjustment::Invert)));
|
||||
assert_eq!(step_damage(&before, &adjusted), None);
|
||||
assert_eq!(step_damage(&adjusted, &before), None);
|
||||
// Empty layers draw nothing, in any blend mode.
|
||||
let mut styled = before.clone();
|
||||
let mut l = Layer::raster("styled", styled.pixel_format());
|
||||
l.blend = photocraft_doc::BlendMode::Multiply;
|
||||
styled.insert_above(None, l);
|
||||
assert_eq!(step_damage(&before, &styled), None);
|
||||
// An empty layer in Normal mode is.
|
||||
assert_eq!(step_damage(&before, &styled), Some(Rect::EMPTY));
|
||||
let mut plain = before.clone();
|
||||
plain.insert_above(None, Layer::raster("plain", plain.pixel_format()));
|
||||
assert_eq!(step_damage(&before, &plain), Some(Rect::EMPTY));
|
||||
|
|
@ -1673,6 +1690,40 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// The composite of `after` equals `before`'s outside `damage`.
|
||||
fn unchanged_outside(before: &Document, after: &Document, damage: Rect) -> bool {
|
||||
let (x, y) = (photocraft_compose::render(before, before.bounds()), photocraft_compose::render(after, after.bounds()));
|
||||
let w = before.bounds().width() as i32;
|
||||
x.px.iter().zip(&y.px).enumerate().all(|(i, (p, q))| p == q || damage.contains(i as i32 % w, i as i32 / w))
|
||||
}
|
||||
|
||||
/// Duplicate, paste and delete, and their undo and redo, recomposite only where the layer
|
||||
/// draws; undoing a duplicate used to recomposite the whole document.
|
||||
#[test]
|
||||
fn duplicate_paste_and_delete_report_where_the_layer_draws() {
|
||||
let mut s = session(8);
|
||||
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||||
let full = doc(&s).bounds();
|
||||
let step = |s: &mut Session, id: &str, p: serde_json::Value| {
|
||||
let before = doc(s).clone();
|
||||
s.execute(id, p).unwrap();
|
||||
let d = damage(s).unwrap_or_else(|| panic!("{id}: unbounded damage"));
|
||||
assert!(d.width() < full.width(), "{id}: {d:?}");
|
||||
assert!(unchanged_outside(&before, doc(s), d), "{id}: pixels changed outside {d:?}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(damage(s), Some(d), "undo {id}");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(damage(s), Some(d), "redo {id}");
|
||||
};
|
||||
step(&mut s, "layer.duplicate", json!({"layer": a.0}));
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 12, "y": 12, "width": 4, "height": 4})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
step(&mut s, "edit.paste", json!({}));
|
||||
step(&mut s, "layer.delete", json!({"layer": a.0}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_move_preview_matches_the_move() {
|
||||
let mut s = session(8);
|
||||
|
|
@ -2209,12 +2260,12 @@ mod tests {
|
|||
assert_ne!(doc(&s).size, photocraft_doc::Size::new(100, 80));
|
||||
s.undo();
|
||||
assert_eq!(damage(&s), None, "a size change recomposites the whole canvas");
|
||||
// A structural change (a layer removed and restored) neither.
|
||||
// A layer removed and restored changes only where it draws.
|
||||
let b = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
|
||||
select_all(&mut s, &[b]);
|
||||
s.execute("layer.delete", json!({})).unwrap();
|
||||
s.undo();
|
||||
assert_eq!(damage(&s), None);
|
||||
assert_eq!(damage(&s), Some(Rect::new(0, 0, 5, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -98,39 +98,250 @@ pub fn effect_defaults(kind: &str) -> Value {
|
|||
/// A contour preset point: input, output (both `0..=1`) and whether it is a corner.
|
||||
pub type ContourPoint = (f32, f32, bool);
|
||||
|
||||
/// Built-in contour presets, in the order of Photoshop's default contour picker. The shapes are
|
||||
/// our own control points, drawn to match each preset's visible curve; imported PSD contours keep
|
||||
/// their own points. Points are sorted by input; a corner point bends the curve sharply.
|
||||
/// Built-in contour presets, in the order of Photoshop's default contour picker. Each is Photoshop's
|
||||
/// own curve as measured in Photoshop 27.11 (a Satin of each preset against a Linear one, 38
|
||||
/// levels per curve, read back through Satin's mirrored lookup — see
|
||||
/// `photocraft_compose::effects::satin_lut` — and thinned to within 1 %), joined by straight
|
||||
/// segments (every inner point a corner). Imported PSD contours keep their own points.
|
||||
pub const CONTOURS: &[(&str, &[ContourPoint])] = &[
|
||||
("Linear", &[(0.0, 0.0, false), (1.0, 1.0, false)]),
|
||||
("Cone", &[(0.0, 0.0, false), (0.5, 1.0, true), (1.0, 0.0, false)]),
|
||||
("Cone - Inverted", &[(0.0, 1.0, false), (0.5, 0.0, true), (1.0, 1.0, false)]),
|
||||
("Cove - Deep", &[(0.0, 0.0, false), (0.3, 0.02, false), (0.6, 0.12, false), (0.85, 0.4, false), (1.0, 1.0, false)]),
|
||||
("Cove - Shallow", &[(0.0, 0.0, false), (0.5, 0.22, false), (1.0, 1.0, false)]),
|
||||
("Gaussian", &[(0.0, 0.0, false), (0.25, 0.08, false), (0.5, 0.5, false), (0.75, 0.92, false), (1.0, 1.0, false)]),
|
||||
("Half Round", &[(0.0, 0.0, false), (0.04, 0.28, false), (0.15, 0.53, false), (0.35, 0.8, false), (0.65, 0.95, false), (1.0, 1.0, false)]),
|
||||
("Ring", &[(0.0, 0.0, false), (0.25, 0.7, false), (0.5, 1.0, false), (0.75, 0.7, false), (1.0, 0.0, false)]),
|
||||
("Ring - Double", &[(0.0, 0.0, false), (0.25, 1.0, false), (0.5, 0.0, true), (0.75, 1.0, false), (1.0, 0.0, false)]),
|
||||
("Rolling Slope - Descending", &[(0.0, 1.0, false), (0.25, 0.62, false), (0.5, 0.58, false), (0.75, 0.28, false), (1.0, 0.0, false)]),
|
||||
(
|
||||
"Rounded Steps",
|
||||
"Cone",
|
||||
&[
|
||||
(0.0, 0.0, false),
|
||||
(0.08, 0.01, false),
|
||||
(0.165, 0.167, false),
|
||||
(0.25, 0.323, false),
|
||||
(0.333, 0.333, true),
|
||||
(0.413, 0.343, false),
|
||||
(0.5, 0.5, false),
|
||||
(0.587, 0.657, false),
|
||||
(0.667, 0.667, true),
|
||||
(0.747, 0.677, false),
|
||||
(0.833, 0.833, false),
|
||||
(0.92, 0.99, false),
|
||||
(0.172, 0.477, true),
|
||||
(0.195, 0.555, true),
|
||||
(0.266, 0.711, true),
|
||||
(0.383, 0.922, true),
|
||||
(0.43, 0.969, true),
|
||||
(0.477, 0.992, true),
|
||||
(0.523, 0.992, true),
|
||||
(0.57, 0.969, true),
|
||||
(0.664, 0.859, true),
|
||||
(0.703, 0.789, true),
|
||||
(0.742, 0.711, true),
|
||||
(0.836, 0.477, true),
|
||||
(0.859, 0.398, true),
|
||||
(0.914, 0.266, true),
|
||||
(0.945, 0.156, true),
|
||||
(0.977, 0.078, true),
|
||||
(1.0, 0.0, false),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Cone - Inverted",
|
||||
&[
|
||||
(0.0, 1.0, false),
|
||||
(0.031, 0.922, true),
|
||||
(0.07, 0.789, true),
|
||||
(0.172, 0.523, true),
|
||||
(0.195, 0.445, true),
|
||||
(0.266, 0.289, true),
|
||||
(0.383, 0.078, true),
|
||||
(0.43, 0.031, true),
|
||||
(0.477, 0.008, true),
|
||||
(0.523, 0.008, true),
|
||||
(0.57, 0.031, true),
|
||||
(0.664, 0.141, true),
|
||||
(0.703, 0.211, true),
|
||||
(0.742, 0.289, true),
|
||||
(0.836, 0.523, true),
|
||||
(0.859, 0.602, true),
|
||||
(0.914, 0.734, true),
|
||||
(0.945, 0.844, true),
|
||||
(0.977, 0.922, true),
|
||||
(1.0, 1.0, false),
|
||||
],
|
||||
),
|
||||
("Sawtooth 1", &[(0.0, 0.0, false), (0.3, 1.0, true), (0.34, 0.0, true), (0.64, 1.0, true), (0.68, 0.0, true), (1.0, 1.0, false)]),
|
||||
(
|
||||
"Cove - Deep",
|
||||
&[
|
||||
(0.0, 0.258, false),
|
||||
(0.344, 0.289, true),
|
||||
(0.383, 0.297, true),
|
||||
(0.477, 0.352, true),
|
||||
(0.57, 0.43, true),
|
||||
(0.617, 0.484, true),
|
||||
(0.664, 0.57, true),
|
||||
(0.703, 0.711, true),
|
||||
(0.742, 0.906, true),
|
||||
(0.773, 1.0, true),
|
||||
(1.0, 1.0, false),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Cove - Shallow",
|
||||
&[
|
||||
(0.0, 0.0, false),
|
||||
(0.008, 0.164, true),
|
||||
(0.016, 0.477, true),
|
||||
(0.039, 0.508, true),
|
||||
(0.172, 0.57, true),
|
||||
(0.305, 0.688, true),
|
||||
(0.344, 0.734, true),
|
||||
(0.477, 0.945, true),
|
||||
(0.523, 1.0, true),
|
||||
(1.0, 1.0, false),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Gaussian",
|
||||
&[
|
||||
(0.0, 0.0, false),
|
||||
(0.117, 0.023, true),
|
||||
(0.172, 0.055, true),
|
||||
(0.234, 0.117, true),
|
||||
(0.305, 0.234, true),
|
||||
(0.383, 0.414, true),
|
||||
(0.523, 0.688, true),
|
||||
(0.617, 0.812, true),
|
||||
(0.703, 0.891, true),
|
||||
(0.805, 0.953, true),
|
||||
(1.0, 1.0, false),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Half Round",
|
||||
&[
|
||||
(0.0, 0.0, false),
|
||||
(0.07, 0.18, true),
|
||||
(0.195, 0.445, true),
|
||||
(0.266, 0.555, true),
|
||||
(0.383, 0.703, true),
|
||||
(0.523, 0.828, true),
|
||||
(0.617, 0.883, true),
|
||||
(0.773, 0.945, true),
|
||||
(1.0, 1.0, false),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Ring",
|
||||
&[
|
||||
(0.0, 0.0, false),
|
||||
(0.031, 0.078, true),
|
||||
(0.039, 0.117, true),
|
||||
(0.117, 0.383, true),
|
||||
(0.172, 0.609, true),
|
||||
(0.195, 0.734, true),
|
||||
(0.266, 0.953, true),
|
||||
(0.305, 1.0, true),
|
||||
(0.344, 0.992, true),
|
||||
(0.383, 0.922, true),
|
||||
(0.43, 0.805, true),
|
||||
(0.477, 0.641, true),
|
||||
(0.57, 0.281, true),
|
||||
(0.617, 0.141, true),
|
||||
(0.664, 0.055, true),
|
||||
(0.703, 0.016, true),
|
||||
(0.742, 0.016, true),
|
||||
(0.773, 0.062, true),
|
||||
(0.836, 0.234, true),
|
||||
(0.859, 0.344, true),
|
||||
(0.891, 0.445, true),
|
||||
(0.914, 0.547, true),
|
||||
(0.945, 0.734, true),
|
||||
(0.977, 0.875, true),
|
||||
(1.0, 1.0, false),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Ring - Double",
|
||||
&[
|
||||
(0.0, 0.0, false),
|
||||
(0.055, 0.086, true),
|
||||
(0.094, 0.172, true),
|
||||
(0.117, 0.242, true),
|
||||
(0.172, 0.477, true),
|
||||
(0.195, 0.625, true),
|
||||
(0.266, 0.891, true),
|
||||
(0.305, 0.945, true),
|
||||
(0.344, 0.891, true),
|
||||
(0.383, 0.695, true),
|
||||
(0.43, 0.367, true),
|
||||
(0.477, 0.07, true),
|
||||
(0.523, 0.047, true),
|
||||
(0.57, 0.32, true),
|
||||
(0.617, 0.648, true),
|
||||
(0.664, 0.867, true),
|
||||
(0.703, 0.93, true),
|
||||
(0.742, 0.852, true),
|
||||
(0.773, 0.641, true),
|
||||
(0.805, 0.375, true),
|
||||
(0.836, 0.156, true),
|
||||
(0.859, 0.031, true),
|
||||
(0.891, 0.023, true),
|
||||
(0.914, 0.109, true),
|
||||
(0.977, 0.703, true),
|
||||
(0.984, 0.82, true),
|
||||
(1.0, 1.0, false),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Rolling Slope - Descending",
|
||||
&[
|
||||
(0.0, 1.0, false),
|
||||
(0.008, 1.0, true),
|
||||
(0.031, 0.93, true),
|
||||
(0.039, 0.883, true),
|
||||
(0.07, 0.758, true),
|
||||
(0.141, 0.539, true),
|
||||
(0.195, 0.406, true),
|
||||
(0.266, 0.352, true),
|
||||
(0.305, 0.352, true),
|
||||
(0.523, 0.57, true),
|
||||
(0.57, 0.602, true),
|
||||
(0.617, 0.617, true),
|
||||
(0.703, 0.602, true),
|
||||
(0.742, 0.562, true),
|
||||
(0.836, 0.391, true),
|
||||
(0.914, 0.211, true),
|
||||
(0.945, 0.109, true),
|
||||
(0.977, 0.039, true),
|
||||
(0.984, 0.008, true),
|
||||
(1.0, 0.0, false),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Rounded Steps",
|
||||
&[
|
||||
(0.0, 0.234, false),
|
||||
(0.031, 0.305, true),
|
||||
(0.039, 0.336, true),
|
||||
(0.094, 0.43, true),
|
||||
(0.141, 0.477, true),
|
||||
(0.234, 0.492, true),
|
||||
(0.266, 0.516, true),
|
||||
(0.305, 0.602, true),
|
||||
(0.344, 0.664, true),
|
||||
(0.383, 0.711, true),
|
||||
(0.477, 0.734, true),
|
||||
(0.523, 0.797, true),
|
||||
(0.57, 0.898, true),
|
||||
(0.617, 0.953, true),
|
||||
(0.664, 0.984, true),
|
||||
(0.773, 0.984, true),
|
||||
(0.836, 0.906, true),
|
||||
(0.891, 0.891, true),
|
||||
(0.914, 0.898, true),
|
||||
(1.0, 1.0, false),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Sawtooth 1",
|
||||
&[
|
||||
(0.0, 0.0, false),
|
||||
(0.031, 0.047, true),
|
||||
(0.195, 0.375, true),
|
||||
(0.266, 0.492, true),
|
||||
(0.477, 0.047, true),
|
||||
(0.523, 0.055, true),
|
||||
(0.742, 0.469, true),
|
||||
(0.773, 0.445, true),
|
||||
(0.914, 0.18, true),
|
||||
(0.945, 0.102, true),
|
||||
(1.0, 0.0, false),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// Names earlier versions offered, still accepted by `contour` params (scripts, saved defaults).
|
||||
|
|
@ -1118,10 +1329,29 @@ mod tests {
|
|||
assert_eq!(lin.contour, Contour::Linear);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contour_presets_match_photoshop() {
|
||||
// Photoshop 27.11, measured through Satin: Cone and Cone − Inverted Satins are identical,
|
||||
// and Gaussian reads 0.36 at 0.5 through Satin but 0.64 as the preset curve (glows, gloss).
|
||||
let lut = |name: &str| {
|
||||
let Some(Contour::Custom { points, corners, .. }) = builtin_contour(name) else { panic!("{name}") };
|
||||
photocraft_compose::adjust::contour_curve_lut(&points, &corners)
|
||||
};
|
||||
let satin = |name: &str| photocraft_compose::effects::satin_lut(&builtin_contour(name).unwrap()).unwrap();
|
||||
let at = |l: &[f32], x: f32| l[(x * (l.len() - 1) as f32).round() as usize];
|
||||
let (cone, inverted) = (satin("Cone"), satin("Cone - Inverted"));
|
||||
for x in [0.1, 0.25, 0.5, 0.8] {
|
||||
assert!((at(&cone, x) - at(&inverted, x)).abs() < 0.03, "{x}: {} vs {}", at(&cone, x), at(&inverted, x));
|
||||
}
|
||||
assert!((at(&satin("Gaussian"), 0.5) - 0.36).abs() < 0.04, "{}", at(&satin("Gaussian"), 0.5));
|
||||
assert!((at(&lut("Gaussian"), 0.5) - 0.64).abs() < 0.03, "{}", at(&lut("Gaussian"), 0.5));
|
||||
assert!((at(&lut("Cove - Deep"), 0.0) - 0.26).abs() < 0.02, "Cove - Deep starts above zero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contour_presets_and_custom_curves_apply_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
// Picking Cone: the preset's own curve, its peak a corner, in one undo step.
|
||||
// Picking Cone: the preset's own curve, its points corners, in one undo step.
|
||||
s.execute("layer.layerStyle.dropShadow", json!({"contour": "Cone", "antiAlias": true})).unwrap();
|
||||
let Effect::DropShadow(sh) = &effects(&s)[0] else { panic!() };
|
||||
assert_eq!(sh.contour, builtin_contour("Cone").unwrap());
|
||||
|
|
@ -1129,7 +1359,9 @@ mod tests {
|
|||
let Contour::Custom { points, corners, .. } = &sh.contour else { panic!() };
|
||||
let lut = photocraft_compose::adjust::contour_curve_lut(points, corners);
|
||||
let at = |x: f32| lut[(x * (lut.len() - 1) as f32).round() as usize];
|
||||
assert!((at(0.5) - 1.0).abs() < 1e-3 && (at(0.25) - 0.5).abs() < 0.01, "a sharp Λ, not a rounded hump");
|
||||
// Photoshop's measured Cone: 0 at both ends, a 0.99 peak at 0.5, symmetric.
|
||||
assert!(at(0.0).abs() < 1e-3 && at(1.0).abs() < 1e-3 && at(0.5) > 0.98, "a Λ: {} {} {}", at(0.0), at(0.5), at(1.0));
|
||||
assert!((at(0.25) - at(0.75)).abs() < 0.03, "symmetric: {} vs {}", at(0.25), at(0.75));
|
||||
assert_eq!(contour_param(&sh.contour), json!("Cone"));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(effects(&s).is_empty(), "one undo removes it");
|
||||
|
|
|
|||
|
|
@ -287,6 +287,7 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg:
|
|||
let canvas = doc.bounds();
|
||||
let selection = doc.selection.clone();
|
||||
let locks = doc.effective_locks(id);
|
||||
let background = doc.is_background(id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let LayerContent::Smart(_) = l.content {
|
||||
let sf = SmartFilter { command: cmd.to_string(), params: stored.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
|
||||
|
|
@ -296,7 +297,7 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg:
|
|||
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
|
||||
}
|
||||
// A Background layer gains transparency when the edge mode exposes it (as in Photoshop).
|
||||
if float_bg && l.locks.position && l.name == "Background" {
|
||||
if float_bg && background {
|
||||
l.locks.position = false;
|
||||
l.locks.transparency = false;
|
||||
l.name = "Layer 0".into();
|
||||
|
|
@ -368,7 +369,28 @@ fn adaptive_wide_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}))
|
||||
}
|
||||
|
||||
/// The transfer curve Camera Raw decodes `doc`'s values with ([`CameraRaw::encoding_gamma`]):
|
||||
/// ProPhoto's gamma 1.8 (raws open in it), Adobe RGB's 563/256, 1 for linear sRGB, else 0 for
|
||||
/// the sRGB curve.
|
||||
pub fn encoding_gamma(doc: &Document) -> f32 {
|
||||
use photocraft_cms::Builtin;
|
||||
let profile = crate::color_cmds::document_profile(doc);
|
||||
[(Builtin::ProPhotoCompat, 1.8), (Builtin::AdobeRgbCompat, 563.0 / 256.0), (Builtin::LinearSrgb, 1.0)]
|
||||
.into_iter()
|
||||
.find(|(b, _)| profile.same_colors(b.profile()))
|
||||
.map_or(0.0, |(_, g)| g)
|
||||
}
|
||||
|
||||
fn camera_raw_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The document's transfer curve goes with the settings, so a Smart Filter keeps it.
|
||||
let mut p = p.clone();
|
||||
if p.get("encodingGamma").is_none()
|
||||
&& let Some(g) = s.active().map(|d| encoding_gamma(&d.doc)).filter(|g| *g > 0.0)
|
||||
&& let Value::Object(m) = &mut p
|
||||
{
|
||||
m.insert("encodingGamma".into(), json!((f64::from(g) * 1e4).round() / 1e4));
|
||||
}
|
||||
let p = &p;
|
||||
let cr = raw_params(RAW, p)?;
|
||||
// New settings are strict; stored Smart Filters re-apply through the lenient `raw_params`.
|
||||
cr.validate().map_err(|e| bad(RAW, e))?;
|
||||
|
|
@ -582,6 +604,32 @@ mod tests {
|
|||
assert!((p[0] - p[2]).abs() < 0.01, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_raw_decodes_light_with_the_documents_curve() {
|
||||
// A raw opens in ProPhoto (gamma 1.8): the filter keeps that with its settings, so Whites,
|
||||
// Blacks, Shadows and Highlights work on the same light as when it was applied.
|
||||
let mut s = session(16);
|
||||
s.execute("edit.assignProfile", json!({"profile": "prophoto-compat"})).unwrap();
|
||||
assert_eq!(encoding_gamma(&s.active().unwrap().doc), 1.8);
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
s.execute(RAW, json!({"shadows": 40, "whites": -30})).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
let filters = match &d.doc.layer(d.active_layer.unwrap()).unwrap().content {
|
||||
LayerContent::Smart(so) => so.smart_filters.clone(),
|
||||
_ => panic!("smart object expected"),
|
||||
};
|
||||
assert_eq!(filters[0].params["encodingGamma"], json!(1.8));
|
||||
// sRGB documents use the sRGB curve, the default, and store nothing.
|
||||
let mut s = session(8);
|
||||
assert_eq!(encoding_gamma(&s.active().unwrap().doc), 0.0);
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
s.execute(RAW, json!({"shadows": 40})).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
if let LayerContent::Smart(so) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content {
|
||||
assert!(so.smart_filters[0].params.get("encodingGamma").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_raw_rejects_an_area_too_large_to_develop() {
|
||||
// #962: `area.width() * area.height()` overflowed u32 on a legal 65536×65536 canvas,
|
||||
|
|
|
|||
|
|
@ -644,11 +644,12 @@ impl Session {
|
|||
st.color_samplers.retain(|p| bounds.contains(p[0].floor() as i32, p[1].floor() as i32));
|
||||
samplers_removed = n - st.color_samplers.len();
|
||||
}
|
||||
// An edit that changes no pixels (a selection, guides, a layer's name) leaves the canvas
|
||||
// as it is; anything else recomposites the whole document unless the command reports
|
||||
// its own damage after this. A new lasso selection on a large document used to cost a
|
||||
// full recomposite (seconds on the CPU path, #1773).
|
||||
let unchanged = layer_multi_cmds::same_pixels(&before, &st.doc);
|
||||
// The pixels the edit can have changed, as its undo would report them: nothing for a
|
||||
// selection, guides or a layer's name (a new lasso selection used to recomposite a large
|
||||
// document, seconds on the CPU path, #1773), a new or deleted layer's bounds (a
|
||||
// duplicate or a paste), and everything when that can't be bounded. A command may still
|
||||
// report its own, tighter damage after this.
|
||||
let damage = layer_multi_cmds::step_damage(&before, &st.doc);
|
||||
let key = self.coalesce_request.clone();
|
||||
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
let layers = st.layer_target();
|
||||
|
|
@ -661,9 +662,9 @@ impl Session {
|
|||
}
|
||||
st.coalesce = key;
|
||||
st.revision += 1;
|
||||
st.last_damage = unchanged.then_some(photocraft_geom::Rect::EMPTY);
|
||||
st.last_damage = damage;
|
||||
self.samplers_removed += samplers_removed;
|
||||
if !unchanged {
|
||||
if damage.is_none_or(|d| !d.is_empty()) {
|
||||
// Free the mask early; a stale entry would only miss (it is keyed by pixels).
|
||||
self.subject_cache = Default::default();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,8 +91,9 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let canvas = doc.bounds();
|
||||
let fmt = doc.pixel_format();
|
||||
let dpi = doc.resolution_dpi;
|
||||
let background_id = doc.background().map(|l| l.id);
|
||||
for l in doc.layers.iter_mut() {
|
||||
let background = l.name == "Background" && l.locks.position && matches!(l.content, LayerContent::Raster(_));
|
||||
let background = Some(l.id) == background_id;
|
||||
if background && let Some(surf) = l.surface_mut() {
|
||||
// The Background stays a Background: rotated pixels over the background colour.
|
||||
let src = surf.content_bounds();
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue