Merge remote-tracking branch 'origin/main' into feat/ui-217-4-animated-zoom

This commit is contained in:
David Mann
2026-10-15 22:12:00 +02:00
16 changed files with 815 additions and 49 deletions
Generated
+2 -2
View File
@@ -6113,9 +6113,9 @@ dependencies = [
[[package]]
name = "ruzstd"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
dependencies = [
"twox-hash",
]
+41 -4
View File
@@ -5,11 +5,12 @@ use std::io::{BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Instant;
use std::time::{Duration, 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, repeat_auth_reply,
AUTH_DEADLINE, AuthDeadlineStream, 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;
@@ -47,11 +48,17 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
}
/// [`serve`] for a connection that must complete `auth` within `auth_deadline` of connecting,
/// however much it sends meanwhile (#4965); after that only the idle timeout applies.
fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_deadline: Duration) {
if configure_stream(&stream).is_err() {
return;
}
let Ok(read) = stream.try_clone() else { return };
let mut reader = BufReader::new(read);
let mut reader = BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
let mut out = stream;
let mut line = String::new();
let mut authenticated = false;
@@ -74,6 +81,9 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
if !authenticated {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
if ok && reader.get_mut().authenticated().is_err() {
break;
}
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated {
break;
}
@@ -120,7 +130,34 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
mod tests {
use super::*;
use std::io::BufRead;
use std::time::Duration;
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
/// (releasing its connection permit), and nothing it sent is dispatched.
#[test]
fn an_unauthenticated_connection_is_closed_at_the_deadline_whatever_it_sends() {
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();
let started = Instant::now();
serve_with_auth_deadline(stream, "unused", tx, egui::Context::default(), Duration::from_millis(300));
started.elapsed()
});
// Blank lines and partial frames, one byte every 20 ms: each read succeeds long before
// the idle timeout.
let mut stream = TcpStream::connect(addr).unwrap();
for i in 0..150 {
let byte: &[u8] = if i % 5 == 0 { b"\n" } else { b" " };
if stream.write_all(byte).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
let served = server.join().unwrap();
assert!(served < Duration::from_secs(3), "the trickle did not extend the deadline: {served:?}");
assert!(rx.try_recv().is_err(), "nothing was dispatched");
}
#[test]
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
+1 -1
View File
@@ -16,7 +16,7 @@ synth = []
[dependencies]
crc32fast = "1.5"
flate2 = { workspace = true }
ruzstd = "0.9"
ruzstd = "0.9.1"
[dev-dependencies]
proptest = { workspace = true }
+64 -5
View File
@@ -29,8 +29,8 @@ 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, repeat_auth_reply,
AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply,
configure_stream, discard_rest_of_line, read_bounded_line, repeat_auth_reply,
};
use crate::{AutomationError, Headless};
@@ -262,10 +262,12 @@ pub fn serve_lines(h: &Mutex<Headless>, mut r: impl BufRead, mut w: impl Write)
}
}
fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token: &str) -> std::io::Result<()> {
/// Serve one connection. It must complete `auth` within `auth_deadline` of connecting, however
/// much it sends meanwhile (#4965); after that only the idle timeout applies.
fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token: &str, auth_deadline: std::time::Duration) -> std::io::Result<()> {
configure_stream(&stream)?;
let read = stream.try_clone()?;
let mut reader = std::io::BufReader::new(read);
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
let mut out = stream;
let mut line = String::new();
let mut authenticated = false;
@@ -290,6 +292,9 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
} else {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
if ok {
reader.get_mut().authenticated()?;
}
reply
};
write_reply(&mut out, &reply)?;
@@ -322,7 +327,7 @@ pub fn serve_tcp(addr: &str, h: Arc<Mutex<Headless>>, token: String, ready: impl
let token = Arc::clone(&token);
std::thread::spawn(move || {
let _permit = permit;
let _ = serve_tcp_connection(&h, stream, &token);
let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE);
});
}
Ok(())
@@ -814,6 +819,60 @@ mod tests {
assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch"));
}
/// #4965: a connection that keeps sending without authenticating is closed at the deadline,
/// and one that authenticated in time keeps being served after it.
#[test]
fn tcp_connections_must_authenticate_before_the_deadline_whatever_they_send() {
use std::io::{BufReader, Write as _};
use std::time::{Duration, Instant};
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let h = Mutex::new(Headless::new());
let mut results = Vec::new();
for _ in 0..2 {
let (stream, _) = listener.accept().unwrap();
let started = Instant::now();
let r = serve_tcp_connection(&h, stream, TOKEN, Duration::from_millis(300));
results.push((r.map_err(|e| e.kind()), started.elapsed()));
}
results
});
// Never a newline, a byte every 20 ms: each read succeeds long before the idle timeout.
let mut slow = std::net::TcpStream::connect(addr).unwrap();
for _ in 0..150 {
if slow.write_all(b" ").is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
drop(slow);
// Authenticates at once, then asks again 300 ms after the deadline.
let mut s = std::net::TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
writeln!(s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
let mut reader = BufReader::new(s.try_clone().unwrap());
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["result"]["authenticated"], true);
std::thread::sleep(Duration::from_millis(600));
writeln!(s, r#"{{"id":"a","method":"methods"}}"#).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["id"], "a");
drop(reader);
drop(s);
let results = server.join().unwrap();
let (slow_result, slow_time) = &results[0];
assert!(matches!(slow_result, Err(std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock)), "{slow_result:?}");
assert!(*slow_time < Duration::from_secs(3), "the trickle did not extend the deadline: {slow_time:?}");
assert_eq!(results[1].0, Ok(()), "the authenticated connection ended with its client");
}
#[test]
fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() {
use std::io::{BufReader, Write as _};
+108 -2
View File
@@ -1,11 +1,11 @@
//! Shared security primitives for the loopback JSON control transports.
use std::fs::OpenOptions;
use std::io::{BufRead, Write};
use std::io::{BufRead, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use std::time::{Duration, Instant};
use serde_json::{Value, json};
@@ -21,6 +21,10 @@ pub const MAX_CONNECTIONS: usize = 16;
pub const MAX_BATCH_STEPS: usize = 256;
/// Idle/read and write timeout for loopback TCP connections.
pub const IO_TIMEOUT: Duration = Duration::from_secs(30);
/// How long a new TCP connection has to authenticate, however active it is. [`IO_TIMEOUT`] starts
/// again with every read, so a client that kept sending without ever completing `auth` held one
/// of the [`MAX_CONNECTIONS`] permits for as long as it liked (#4965).
pub const AUTH_DEADLINE: Duration = Duration::from_secs(10);
const TOKEN_BYTES: usize = 32;
const TOKEN_HEX_LEN: usize = TOKEN_BYTES * 2;
@@ -242,6 +246,44 @@ pub fn configure_stream(stream: &std::net::TcpStream) -> std::io::Result<()> {
stream.set_write_timeout(Some(IO_TIMEOUT))
}
/// The read half of a control connection. Until [`Self::authenticated`] is called, reads fail
/// with `TimedOut` once the authentication deadline has passed, whatever arrived in the meantime;
/// the caller then drops the connection and its [`ConnectionPermit`].
pub struct AuthDeadlineStream {
stream: std::net::TcpStream,
/// `None` once authenticated.
deadline: Option<Instant>,
}
impl AuthDeadlineStream {
/// `stream` must authenticate within `limit` from now (normally [`AUTH_DEADLINE`]).
pub fn new(stream: std::net::TcpStream, limit: Duration) -> Self {
// A limit too large to represent expires at once rather than never.
let now = Instant::now();
Self { stream, deadline: Some(now.checked_add(limit).unwrap_or(now)) }
}
/// The connection authenticated: only the idle timeout applies from here on.
pub fn authenticated(&mut self) -> std::io::Result<()> {
self.deadline = None;
self.stream.set_read_timeout(Some(IO_TIMEOUT))
}
}
impl Read for AuthDeadlineStream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if let Some(deadline) = self.deadline {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "not authenticated in time"));
}
// Wait no longer than what is left, so a silent client is dropped at the deadline too.
self.stream.set_read_timeout(Some(left.min(IO_TIMEOUT)))?;
}
self.stream.read(buf)
}
}
/// Environment-aware token inputs shared by the desktop app and CLI.
pub fn token_inputs(supplied: Option<String>, token_file: Option<PathBuf>) -> (Option<String>, Option<PathBuf>) {
let supplied = supplied.or_else(|| std::env::var("PHOTOCRAFT_CONTROL_TOKEN").ok());
@@ -327,6 +369,70 @@ mod tests {
std::fs::remove_file(path).unwrap();
}
/// A connected loopback pair: the accepted (server) end and the client end.
fn tcp_pair() -> (std::net::TcpStream, std::net::TcpStream) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (server, _) = listener.accept().unwrap();
configure_stream(&server).unwrap();
(server, client)
}
/// #4965: activity must not extend the time a connection may stay unauthenticated.
#[test]
fn an_active_connection_is_dropped_at_the_authentication_deadline() {
let (server, mut client) = tcp_pair();
// A byte every 20 ms and never a newline: each read succeeds long before the idle timeout.
let trickle = std::thread::spawn(move || {
for _ in 0..150 {
if client.write_all(b"x").is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
});
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(300)));
let mut line = String::new();
let started = Instant::now();
let err = read_bounded_line(&mut reader, &mut line).unwrap_err();
assert!(matches!(err.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock), "{err}");
assert!(started.elapsed() >= Duration::from_millis(250), "not before the deadline: {:?}", started.elapsed());
assert!(started.elapsed() < Duration::from_secs(3), "the trickle did not extend it: {:?}", started.elapsed());
// It stays expired.
assert!(read_bounded_line(&mut reader, &mut line).is_err());
drop(reader);
trickle.join().unwrap();
}
#[test]
fn a_silent_connection_is_dropped_at_the_authentication_deadline() {
let (server, _client) = tcp_pair();
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
let mut line = String::new();
let started = Instant::now();
assert!(read_bounded_line(&mut reader, &mut line).is_err());
assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
}
#[test]
fn an_authenticated_connection_outlives_the_authentication_deadline() {
let (server, mut client) = tcp_pair();
let sender = std::thread::spawn(move || {
client.write_all(b"one\n").unwrap();
std::thread::sleep(Duration::from_millis(500));
client.write_all(b"two\n").unwrap();
});
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
let mut line = String::new();
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "one\n");
reader.get_mut().authenticated().unwrap();
// Sent 300 ms after the deadline: only the idle timeout applies now.
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "two\n");
sender.join().unwrap();
}
#[test]
fn connection_limiter_releases_capacity() {
let limiter = ConnectionLimiter::new(1);
+174 -21
View File
@@ -23,7 +23,7 @@ use half::f16;
use crate::Format;
use crate::error::CodecError;
use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, SampleType};
use crate::options::Limits;
use crate::options::{Limits, alloc_zeroed};
const F: Format = Format::OpenExr;
@@ -39,9 +39,15 @@ fn unsup(reason: impl Into<String>) -> CodecError {
// Every length and offset comes from the file, so every read is bounds-checked and a short
// read is a malformed file, never a panic.
fn read_exact(cur: &mut Cursor<&[u8]>, n: usize, what: &str) -> Result<Vec<u8>, CodecError> {
let mut v = vec![0u8; n];
cur.read_exact(&mut v).map_err(|_| err(format!("truncated file while reading {what}")))?;
fn read_exact<'a>(cur: &mut Cursor<&'a [u8]>, n: usize, what: &str) -> Result<&'a [u8], CodecError> {
// Borrow packed payloads from the input. Copying them here allocates before count-table
// admission and needlessly duplicates the table even when the second pass just skips it.
let truncated = || err(format!("truncated file while reading {what}"));
let start = usize::try_from(cur.position()).map_err(|_| truncated())?;
let end = start.checked_add(n).ok_or_else(truncated)?;
let bytes = *cur.get_ref();
let v = bytes.get(start..end).ok_or_else(truncated)?;
cur.set_position(end as u64);
Ok(v)
}
@@ -81,10 +87,11 @@ fn differences_to_samples(buf: &mut [u8]) {
}
/// Undo the ZIP half-split: the stream is `[first half][second half]` with the second half
/// holding every second byte of the original; weave them back together.
fn interleave_byte_blocks(buf: &mut [u8]) {
/// holding every second byte of the original; weave them back together. The scratch copy is
/// allocated fallibly, so a buffer that passed `max_alloc` but not the allocator is an error.
fn interleave_byte_blocks(buf: &mut [u8]) -> Result<(), CodecError> {
let half = buf.len().div_ceil(2);
let mut out = vec![0u8; buf.len()];
let mut out = alloc_zeroed::<u8>(buf.len())?;
let mut k = 0usize;
for i in 0..buf.len() / 2 {
let (a, b) = (buf.get(i).copied(), buf.get(half + i).copied());
@@ -105,12 +112,29 @@ fn interleave_byte_blocks(buf: &mut [u8]) {
}
}
buf.copy_from_slice(&out);
Ok(())
}
/// Make room in `out` for `extra` more bytes, `limit` in all. Grows by doubling like `Vec`,
/// but never reserves past `limit`: callers admit exactly that size against `max_alloc`, and
/// plain doubling reserved up to twice as much. Fallible, so a failed allocation is an error.
fn reserve_up_to(out: &mut Vec<u8>, extra: usize, limit: usize, what: &str) -> Result<(), CodecError> {
let need = out.len().saturating_add(extra);
if need > limit {
return Err(err(format!("{what}: data expands past {limit} bytes")));
}
if need > out.capacity() {
let target = out.capacity().saturating_mul(2).max(need).min(limit);
out.try_reserve_exact(target.saturating_sub(out.len())).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {target} bytes for {what}")))?;
}
Ok(())
}
/// OpenEXR RLE tokens: a negative byte `n` means `-n` literal bytes follow, a non-negative
/// byte `n` means the next byte repeats `n + 1` times (as `ImfRle.cpp` and the `exr` crate).
fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
let mut out = Vec::with_capacity(expected.min(1 << 20));
let mut out = Vec::new();
reserve_up_to(&mut out, expected.min(1 << 20), expected, what)?;
let mut i = 0;
while i < src.len() {
let count = src[i] as i8;
@@ -119,6 +143,7 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecE
if out.len().saturating_add(n) > expected {
return Err(err(format!("{what}: RLE data expands past {expected} bytes")));
}
reserve_up_to(&mut out, n, expected, what)?;
if count < 0 {
let end = i.saturating_add(n);
let run = src.get(i..end.min(src.len())).ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?;
@@ -143,7 +168,10 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecE
/// has the uncompressed length is stored raw whatever the declared compression.
fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
if packed.len() == expected {
return Ok(packed.to_vec());
let mut out = Vec::new();
reserve_up_to(&mut out, expected, expected, what)?;
out.extend_from_slice(packed);
return Ok(out);
}
match compression {
Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))),
@@ -151,20 +179,38 @@ fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &s
// Like ZIPS, RLE runs over the predicted, byte-split stream.
let mut out = rle_decode(packed, expected, what)?;
differences_to_samples(&mut out);
interleave_byte_blocks(&mut out);
interleave_byte_blocks(&mut out)?;
Ok(out)
}
Compression::ZIP1 => {
let mut out = Vec::with_capacity(expected.min(1 << 20));
flate2::read::ZlibDecoder::new(packed)
.take(expected as u64 + 1)
.read_to_end(&mut out)
.map_err(|_| err(format!("{what}: zlib data is malformed")))?;
// Inflate straight into `out`, grown with `reserve_up_to` rather than by
// `read_to_end`, whose doubling reserves past `expected`. Once `expected` bytes are
// in, a one-byte read checks that the stream ends there.
let malformed = || err(format!("{what}: zlib data is malformed"));
let mut zlib = flate2::read::ZlibDecoder::new(packed);
let mut out = Vec::new();
let mut filled = 0;
while filled < expected {
if filled == out.len() {
let grow = out.len().max(1 << 13).min(expected - filled);
reserve_up_to(&mut out, grow, expected, what)?;
out.resize(filled + grow, 0);
}
let n = zlib.read(out.get_mut(filled..).unwrap_or_default()).map_err(|_| malformed())?;
if n == 0 {
break;
}
filled += n;
}
out.truncate(filled);
if filled == expected && zlib.read(&mut [0u8; 1]).map_err(|_| malformed())? != 0 {
return Err(err(format!("{what}: zlib data expands past {expected} bytes")));
}
if out.len() != expected {
return Err(err(format!("{what}: zlib data expands to {} bytes, expected {expected}", out.len())));
}
differences_to_samples(&mut out);
interleave_byte_blocks(&mut out);
interleave_byte_blocks(&mut out)?;
Ok(out)
}
other => Err(unsup(format!("deep data with {other:?} compression (NONE, RLE and ZIPS are supported)"))),
@@ -340,13 +386,41 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) {
return Err(err("deep chunk sizes point past the end of the file"));
}
let table_bytes = read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
let what = "the sample-count table";
let table_bytes = read_exact(&mut cur, packed_table as usize, what)?;
let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?;
// A compressed clipped table also needs its scratch copy. The data-window check only
// budgets four bytes per channel per pixel, insufficient for a one-channel image.
// An equal-sized payload takes decompress's raw path, which allocates no scratch.
let buffers = if table_bytes.len() == expected_table { 1 } else { 2 };
let clipped_need = (expected_table as u64).saturating_mul(buffers);
if clipped_need > limits.max_alloc {
return Err(CodecError::LimitExceeded(format!(
"{what} needs {clipped_need} bytes including decompression scratch, exceeding max_alloc {}",
limits.max_alloc
)));
}
// An edge tile's table may still be stored at the full tile size (its pixels first, in
// rows of the clipped width, then unused entries), as the OpenEXR library writes it.
let full_table = tile_w.saturating_mul(tile_h).saturating_mul(4).max(expected_table);
let raw = decompress(compression, &table_bytes, full_table, "the sample-count table")
.or_else(|_| decompress(compression, &table_bytes, expected_table, "the sample-count table"))?;
// That size comes from the header's tile size alone, which nothing ties to the data
// window, so it is tried only when `max_alloc` has room for the table and for the copy
// that undoes its byte split. Otherwise only the clipped size is read.
let full_table = tile_w.checked_mul(tile_h).and_then(|n| n.checked_mul(4));
let raw = match full_table {
// Scan lines and interior tiles: the full size is the clipped size.
Some(full) if full <= expected_table => decompress(compression, table_bytes, expected_table, what)?,
Some(full) if (full as u64).saturating_mul(2) <= limits.max_alloc => {
decompress(compression, table_bytes, full, what).or_else(|_| decompress(compression, table_bytes, expected_table, what))?
}
_ => decompress(compression, table_bytes, expected_table, what).map_err(|_| {
// Four bytes per pixel of the tile, twice: the table and its scratch copy.
let need = (tile_w as u64).saturating_mul(tile_h as u64).saturating_mul(8);
CodecError::LimitExceeded(format!(
"{what} of a {tile_w}x{tile_h} deep tile does not decode at the clipped size, and at the full tile size it needs {need} bytes with its scratch copy, exceeding max_alloc {}",
limits.max_alloc
))
})?,
};
// The table is cumulative within each row of the block (a tile restarts at every row);
// `prefix` is cumulative over the whole block, and each pixel's own count is the difference.
@@ -423,7 +497,7 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
// The count table sits between the sizes and the sample data; skip its bytes.
read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
let data_bytes = read_exact(&mut cur, chunk.packed_data as usize, "the deep sample data")?;
let raw = decompress(compression, &data_bytes, chunk.unpacked_data as usize, "the deep sample data")?;
let raw = decompress(compression, data_bytes, chunk.unpacked_data as usize, "the deep sample data")?;
// Row by row (a scan line block is one row), and within a row channel by channel, as
// flat tiles are laid out: each pixel's samples are contiguous within its channel.
let mut in_off = 0usize;
@@ -611,3 +685,82 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result<Image, CodecError> {
img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples });
Ok(img)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
#[test]
fn packed_reads_borrow_input_and_reject_bad_sizes_before_allocation() {
let bytes = [1u8, 2, 3, 4];
let mut cur = Cursor::new(bytes.as_slice());
let prefix = read_exact(&mut cur, 3, "x").expect("three input bytes");
assert_eq!(prefix, &[1, 2, 3]);
assert_eq!(prefix.as_ptr(), bytes.as_ptr());
assert_eq!(cur.position(), 3);
assert!(matches!(read_exact(&mut cur, usize::MAX, "x"), Err(CodecError::Malformed { .. })));
assert_eq!(cur.position(), 3);
assert_eq!(read_exact(&mut cur, 1, "x").expect("last byte"), &[4]);
assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. })));
cur.set_position(u64::MAX);
assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. })));
}
/// What the writer stores before compressing: the byte split, then the +128 predictor.
fn predicted(raw: &[u8]) -> Vec<u8> {
let half = raw.len().div_ceil(2);
let mut out = vec![0u8; raw.len()];
for (i, &v) in raw.iter().enumerate() {
out[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = v;
}
let mut prev = 0i16;
for (i, b) in out.iter_mut().enumerate() {
let v = i16::from(*b);
if i > 0 {
*b = (v - prev + 128) as u8;
}
prev = v;
}
out
}
/// #4958: callers admit `expected` bytes for a decompressed buffer (twice that for a count
/// table, with the copy that undoes its byte split), so decompression must not reserve
/// more. `Vec`'s doubling gave a 4 MiB ZIPS table 8 MiB of capacity and a 3 MiB RLE table
/// 4 MiB, so the "admitted" table and its copy took up to three times the table size.
#[test]
fn decompressed_buffers_reserve_no_more_than_expected() {
for n in [1usize << 20, 2 << 20, (3 << 20) + 1, 4 << 20, 5 << 20] {
let raw = vec![0u8; n];
let p = predicted(&raw);
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
z.write_all(&p).expect("write to a Vec");
let zips = z.finish().expect("finish a Vec");
// RLE: the first byte as a literal, then runs of up to 128 copies of the next.
let mut rle = vec![0xFF, p[0]];
for run in p[1..].chunks(128) {
rle.extend_from_slice(&[(run.len() - 1) as u8, run[0]]);
}
for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] {
let out = decompress(compression, packed, n, "the table").expect("decompresses");
assert_eq!(out, raw, "{compression:?}, {n} bytes");
assert!(out.capacity() <= n, "{compression:?}: a {n} byte buffer reserved {} bytes", out.capacity());
}
// One byte short, the same data expands past the expected size and is refused.
for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] {
assert!(decompress(compression, packed, n - 1, "the table").is_err(), "{compression:?}, {n} bytes");
}
}
}
/// A size past the limit is malformed data, and a reservation the allocator refuses is an
/// error rather than an abort.
#[test]
fn reservations_past_the_limit_or_the_allocator_are_errors() {
let mut v = Vec::new();
assert!(matches!(reserve_up_to(&mut v, 2, 1, "x"), Err(CodecError::Malformed { .. })));
assert!(matches!(reserve_up_to(&mut v, isize::MAX as usize + 1, usize::MAX, "x"), Err(CodecError::LimitExceeded(_))));
assert_eq!(v.capacity(), 0);
}
}
+151 -1
View File
@@ -118,6 +118,23 @@ fn gen_deep(
tile: Option<(usize, usize)>,
counts: &[u32],
sample: &dyn Fn(usize, usize) -> Vec<f32>,
) -> Vec<u8> {
gen_deep_tables(w, h, channels, compression, tile, counts, sample, false)
}
/// [`gen_deep`]; with `full_tile_tables` every tile's count table is stored at the full tile
/// size, as the OpenEXR library writes edge tiles: the block's entries first (in rows of its
/// clipped width), then zeros.
#[allow(clippy::too_many_arguments)]
fn gen_deep_tables(
w: usize,
h: usize,
channels: &[Chan],
compression: u8,
tile: Option<(usize, usize)>,
counts: &[u32],
sample: &dyn Fn(usize, usize) -> Vec<f32>,
full_tile_tables: bool,
) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&0x0131_2f76u32.to_le_bytes());
@@ -216,6 +233,10 @@ fn gen_deep(
table.extend_from_slice(&acc.to_le_bytes());
}
}
assert_eq!(table.len(), table_len(b));
if let (true, Some((tw, th))) = (full_tile_tables, tile) {
table.resize(tw * th * 4, 0);
}
let mut data = Vec::new();
for row in b.pixels.chunks(b.row_w) {
for (ci, ch) in channels.iter().enumerate() {
@@ -237,7 +258,6 @@ fn gen_deep(
c.extend_from_slice(&(data.len() as u64).to_le_bytes());
c.extend_from_slice(&ct);
c.extend_from_slice(&cd);
assert_eq!(table.len(), table_len(b));
assert_eq!(data.len(), data_len(b));
chunks.push((bi, c));
}
@@ -608,6 +628,136 @@ fn hostile_sizes_and_offsets_error() {
assert!(matches!(decode_deep_exr(&far, &Limits::none()), Err(CodecError::Malformed { .. })));
}
/// #4958: a deep tile's count table may be stored at the full tile size, and that size comes
/// from the header alone: nothing ties it to the data window. One 1024×1024 tile over a 4×4
/// image, its table stored at that size, makes a compressed file of tens of KB whose table
/// inflates to 4 MiB, plus as much again for the copy that undoes the byte split. Under a
/// budget smaller than both the table must be refused before it is allocated; with room for
/// both it decodes. Tables stored at the clipped size, and the full-size edge tables of
/// ordinary tiles, still decode under a small budget.
#[test]
fn deep_tile_count_table_is_admitted_against_max_alloc() {
let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };
let tight = budget(1 << 20);
let table: u64 = 1024 * 1024 * 4; // the full tile's count table, in bytes
let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines");
for compression in [0u8, 1, 2] {
let big = gen_deep_tables(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba, true);
assert_eq!(decode_deep_exr(&big, &Limits::none()).expect("full-size table, no limits"), scan, "compression {compression}");
let got = decode_deep_exr(&big, &tight);
assert!(
matches!(got, Err(CodecError::LimitExceeded(_))),
"compression {compression}: a 4 MiB count table under a 1 MiB budget gave {:?}",
got.map(|d| d.total_samples())
);
let flat = photocraft_codecs::decode_with(&big, &photocraft_codecs::DecodeOptions { limits: tight, ..Default::default() });
assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "compression {compression}: the flat decode gave {:?}", flat.map(|i| i.width()));
// The same tile with its table stored at the clipped size fits the small budget.
let clipped = gen_deep(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba);
assert_eq!(decode_deep_exr(&clipped, &tight).expect("clipped table, 1 MiB budget"), scan, "compression {compression}");
if compression != 0 {
assert!(big.len() < 1 << 20, "compression {compression}: the file is {} bytes", big.len());
}
// The table and its scratch copy are admitted together: 8 MiB.
assert_eq!(decode_deep_exr(&big, &budget(2 * table)).expect("room for the table and its copy"), scan, "compression {compression}");
let got = decode_deep_exr(&big, &budget(2 * table - 1));
assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "compression {compression}: one byte short gave {:?}", got.map(|d| d.total_samples()));
}
// Ordinary tiles: full-size edge tables decode under the small budget, like clipped ones.
let counts: Vec<u32> = (0..35).map(|p| (p * 3) % 4).collect();
let sample = |p: usize, ci: usize| -> Vec<f32> { (0..counts[p]).map(|k| [0.5, 0.25, 0.125 * k as f32, 1.0, 10.0 - k as f32][ci]).collect() };
for compression in [0u8, 1, 2] {
let full = gen_deep_tables(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample, true);
let clipped = gen_deep(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample);
assert_eq!(
decode_deep_exr(&full, &tight).expect("full-size edge tables"),
decode_deep_exr(&clipped, &tight).expect("clipped edge tables"),
"compression {compression}"
);
}
}
/// #4958, the edges of that admission. A tile of the largest size exr accepts (2^30 - 2 a side,
/// a 2^62 byte count table) decodes from a clipped table under every budget, including no limits,
/// where its full size passes the check and is only tried as the data arrives. Each tile is
/// admitted on its own, because its table is freed before the next chunk: three 16×16 tiles over
/// a 48×1 image are all edge tiles, so every full-size table reaches the check (an interior tile's
/// full size equals its clipped size, so it uses the clipped check). They need 2 KiB each for
/// the table and its copy, 6 KiB in all, and decode under a budget of 4096 bytes. Half and uint
/// channels decode after full-size tables like float ones.
#[test]
fn deep_tile_table_admission_edges() {
let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };
let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines");
let side = (1 << 30) - 2;
for compression in [0u8, 1, 2] {
let bytes = gen_deep(4, 4, &RGBAZ, compression, Some((side, side)), COUNTS_ONE, &sample_rgba);
for limits in [Limits::none(), Limits::default(), budget(1 << 20)] {
assert_eq!(decode_deep_exr(&bytes, &limits).expect("the largest tile, clipped table"), scan, "compression {compression}, {limits:?}");
}
}
let counts: Vec<u32> = (0..48).map(|p| (p % 3) as u32).collect();
let half: [Chan; 5] = RGBAZ.map(|c| Chan { ty: 1, ..c });
let [a, b, g, r, z] = RGBAZ;
let with_id = [a, b, g, r, z, Chan { name: "id", ty: 0 }];
for channels in [&RGBAZ[..], &half[..], &with_id[..]] {
let sample = |p: usize, ci: usize| -> Vec<f32> { (0..counts[p]).map(|k| [0.5, 0.25, 0.125, 1.0, 2.0 + k as f32, 7.0][ci]).collect() };
for compression in [0u8, 1, 2] {
let full = gen_deep_tables(48, 1, channels, compression, Some((16, 16)), &counts, &sample, true);
let clipped = gen_deep(48, 1, channels, compression, Some((16, 16)), &counts, &sample);
let what = format!("{} channels, compression {compression}", channels.len());
let want = decode_deep_exr(&clipped, &Limits::none()).expect("clipped tables");
assert_eq!(decode_deep_exr(&full, &budget(4096)).expect("full-size tables, admitted one at a time"), want, "{what}");
}
}
}
/// A one-channel data-window check admits only four bytes per pixel, but a compressed count
/// table and its scratch copy need eight. Cover scanlines, interior tiles and the clipped fallback;
/// the existing multichannel cases have enough pixel allowance to hide this gap.
#[test]
fn clipped_count_tables_include_scratch_in_the_budget() {
let channels = [Chan { name: "Y", ty: 2 }];
let counts = vec![0u32; 64];
let sample = |_: usize, _: usize| Vec::new();
let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };
for tile in [None, Some((64, 1)), Some((128, 2))] {
for compression in [0u8, 1, 2] {
let bytes = gen_deep(64, 1, &channels, compression, tile, &counts, &sample);
let what = format!("tile {tile:?}, compression {compression}");
let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode");
let required = if compression == 0 { 256 } else { 512 };
assert_eq!(decode_deep_exr(&bytes, &budget(required)).expect("table and any scratch fit exactly"), want, "{what}");
assert!(matches!(decode_deep_exr(&bytes, &budget(required - 1)), Err(CodecError::LimitExceeded(_))), "{what}");
let flat = photocraft_codecs::decode_with(&bytes, &photocraft_codecs::DecodeOptions { limits: budget(required - 1), ..Default::default() });
assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}");
if compression == 0 {
assert_eq!(decode_deep_exr(&bytes, &budget(511)).expect("raw table needs no scratch"), want, "{what}");
}
}
}
}
/// RLE and ZIPS store incompressible tables raw too. Their actual payload, not the header's
/// compression tag, decides whether a scratch buffer is needed.
#[test]
fn raw_count_tables_need_no_scratch_even_with_compression() {
let channels = [Chan { name: "Y", ty: 2 }];
let limits = Limits { max_alloc: 4, ..Limits::none() };
for tile in [None, Some((1, 1)), Some((2, 2))] {
for compression in [0u8, 1, 2] {
let bytes = gen_deep(1, 1, &channels, compression, tile, &[0], &|_, _| Vec::new());
let header_end = header_end_of(&bytes);
let chunk = u64::from_le_bytes(bytes[header_end..header_end + 8].try_into().expect("chunk offset")) as usize;
let table_size = chunk + if tile.is_some() { 16 } else { 4 };
let packed = u64::from_le_bytes(bytes[table_size..table_size + 8].try_into().expect("table size"));
assert_eq!(packed, 4, "the four-byte count table is stored raw");
let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode");
assert_eq!(decode_deep_exr(&bytes, &limits).expect("raw table fits"), want, "tile {tile:?}, compression {compression}");
}
}
}
/// Walk the attributes like the format prescribes: the offset table starts after the
/// header's terminating zero byte.
fn header_end_of(bytes: &[u8]) -> usize {
+13
View File
@@ -42,6 +42,11 @@ fn group_of(doc: &Document, id: LayerId) -> Vec<LayerId> {
if group.len() > 1 { group } else { Vec::new() }
}
/// The base `id` clips to (itself when it is a base), when it is in a clip group.
pub(crate) fn base_of(doc: &Document, id: LayerId) -> Option<LayerId> {
group_of(doc, id).first().copied()
}
/// The layers clipped to `id`: the clipped run directly above it. Empty when `id` is itself
/// clipped (it is not a base) or nothing is clipped to it.
fn clipped_to(doc: &Document, id: LayerId) -> Vec<LayerId> {
@@ -51,6 +56,14 @@ fn clipped_to(doc: &Document, id: LayerId) -> Vec<LayerId> {
}
}
/// The clipped layers directly above `id` among its siblings (Release Clipping Mask releases them
/// with it).
pub(crate) fn clipped_above(doc: &Document, id: LayerId) -> Vec<LayerId> {
let Some(path) = doc.path_of(id) else { return Vec::new() };
let Some((list, idx)) = siblings(doc, &path) else { return Vec::new() };
list.iter().skip(idx.saturating_add(1)).take_while(|l| l.clipped).map(|l| l.id).collect()
}
/// The clip groups of layers about to move or be deleted, read before they leave their slots.
#[derive(Default)]
pub(crate) struct Before {
+18 -1
View File
@@ -253,7 +253,10 @@ pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> {
}
/// Layer › Create / Release Clipping Mask. With a `layer` param, just that layer. Otherwise, as in
/// Photoshop, the selection (#1248): Create clips every selected layer except the lowest, which
/// becomes the base; Release releases every selected layer. One history step either way.
/// becomes the base; Release releases every selected layer. Releasing a clipped layer also
/// releases the clipped layers above it in its group, as Adobe documents ("removes the selected
/// layer and any layers above it from the clipping mask"), so none is left clipped to a layer
/// that is no longer clipped. One history step either way.
fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result<Value> {
let label = if clip { "Create Clipping Mask" } else { "Release Clipping Mask" };
let ids: Vec<LayerId> = if p.get("layer").is_some() {
@@ -267,6 +270,20 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result<Value> {
(false, all) => all.to_vec(),
}
};
let ids = if clip {
ids
} else {
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let mut all = ids.clone();
for id in ids.iter().filter(|id| doc.layer(**id).is_some_and(|l| l.clipped)) {
for above in crate::clip_group::clipped_above(doc, *id) {
if !all.contains(&above) {
all.push(above);
}
}
}
all
};
s.edit(label, |doc, _| {
for id in &ids {
doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.clipped = clip;
+35 -6
View File
@@ -522,11 +522,16 @@ fn delete_where(s: &mut Session, label: &str, pred: fn(&Layer) -> bool) -> Resul
n
}
let backup = doc.layers.clone();
// The clip groups of the doomed layers, read before they go (clip_group rule 3: layers
// clipped to a deleted base are released).
let doomed: Vec<LayerId> = doc.walk().into_iter().filter(|(_, _, l)| pred(l)).map(|(_, _, l)| l.id).collect();
let before = crate::clip_group::Before::read(doc, &doomed);
let n = rec(&mut doc.layers, pred);
if doc.layers.is_empty() {
doc.layers = backup;
return Err(EngineError::Other("a document must keep at least one layer".into()));
}
crate::clip_group::settle(doc, &[], &before, false);
if active.is_none_or(|id| doc.layer(id).is_none()) {
*active = doc.top_layer();
}
@@ -878,8 +883,17 @@ pub fn specs() -> Vec<CommandSpec> {
),
spec!("layer.layerStyle.copyLayerStyle", "Copy Layer Style", &["Layer", "Layer Style"], None, r##"{"layer":id?}"##, has_layer, |s, p| {
let id = layer_param(s, p)?;
let l = s.active().and_then(|d| d.doc.layer(id)).ok_or(EngineError::NoLayer(id))?;
let style = (l.effects.clone(), l.blend, l.fill_opacity, l.advanced);
let d = s.active().ok_or(EngineError::NoDocument)?;
let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let style = crate::CopiedStyle {
effects: l.effects.clone(),
blend: l.blend,
fill: l.fill_opacity,
advanced: l.advanced,
blend_if: l.blend_if.clone(),
excluded_channels: l.excluded_channels,
mode: d.doc.mode,
};
s.style_clipboard = Some(style);
Ok(Value::Null)
}),
@@ -895,17 +909,32 @@ pub fn specs() -> Vec<CommandSpec> {
},
|s, p| {
let ids = style_targets(s, p, |_| true)?;
let (mut fx, blend, fill, advanced) = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?;
let style = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?;
let mut fx = style.effects;
s.edit("Paste Layer Style", |doc, _| {
// A style copied from a document in another mode takes this one's colours.
let mode = doc.mode;
fx.items = std::mem::take(&mut fx.items).into_iter().map(|e| e.in_mode(mode)).collect();
// Blend If's Gray range carries over to any mode; its channel ranges and the
// channel restrictions only to the mode they were set in.
let same_mode = style.mode == mode;
let blend_if = if same_mode {
style.blend_if.clone()
} else {
// Through `set`, which keeps the normalised form (no trailing full
// entries), so a full Gray range pastes as the default.
let mut gray = photocraft_doc::BlendIf::default();
gray.set(0, style.blend_if.get(0));
gray
};
for id in ids {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.effects = fx.clone();
l.blend = blend;
l.fill_opacity = fill;
l.advanced = advanced;
l.blend = style.blend;
l.fill_opacity = style.fill;
l.advanced = style.advanced;
l.blend_if = blend_if.clone();
l.excluded_channels = if same_mode { style.excluded_channels } else { 0 };
}
Ok(())
})?;
+12 -4
View File
@@ -263,7 +263,8 @@ fn can_align(s: &Session) -> std::result::Result<(), String> {
let d = doc_state(s)?;
match d.selected_layers().len() {
0 => Err("no layer selected".into()),
// Photoshop aligns a single layer to the canvas (or to an active selection); 2+ align to each other.
// Photoshop aligns to an active selection however many layers are selected; without one,
// a single layer aligns to the canvas and 2+ align to each other.
_ => Ok(()),
}
}
@@ -961,8 +962,9 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
"canvas" => doc.bounds(),
"selection" => doc.selection.as_ref().map(|m| m.content_bounds()).ok_or_else(|| EngineError::Other("there is no selection to align to".into()))?,
"auto" | "layers" => match (&doc.selection, sel.len()) {
// Photoshop: one layer plus an active selection aligns to the selection bounds.
(Some(m), 1) if to == "auto" => m.content_bounds(),
// Photoshop: with an active selection, the layers align to its bounds (Layer › Align
// Layers to Selection), however many are selected.
(Some(m), _) if to == "auto" && !m.content_bounds().is_empty() => m.content_bounds(),
// One layer with no selection aligns to the canvas (otherwise it would align to itself).
(None, 1) if to == "auto" => doc.bounds(),
_ => items.iter().fold(Rect::EMPTY, |a, (_, b)| a.union(b)),
@@ -1312,6 +1314,12 @@ fn merge_layers(s: &mut Session) -> Result<Value> {
let fmt = PixelFormat::new(fmt.mode, fmt.sample, true);
let mut merged = Layer::raster(top.name.clone(), fmt);
merged.locks = top.locks;
merged.label = top.label;
// Clipped layers of one chain merge into a layer still clipped to their base. Clipped
// layers of different chains don't: the result, at the top one's slot, would clip the
// lower chain's pixels to the upper chain's base.
let base = crate::clip_group::base_of(doc, top_id);
merged.clipped = base.is_some() && solo.layers.iter().all(|l| l.clipped && crate::clip_group::base_of(doc, l.id) == base);
*crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt);
let mid = merged.id;
// Hidden selected layers are discarded, as in Photoshop.
@@ -1431,7 +1439,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run }
};
}
const ALIGN: &str = r##"{"to":"auto|layers|selection|canvas"="auto"} (auto: the selection bounds with one layer and an active selection, else the selected layers' bounds)"##;
const ALIGN: &str = r##"{"to":"auto|layers|selection|canvas"="auto"} (auto: the active selection's bounds when there is one, however many layers are selected; else one layer aligns to the canvas and several to their combined bounds)"##;
vec![
spec!("select.allLayers", "All Layers", &["Select"], Some("Cmd+Alt+A"), "{}", has_doc, |s, _| select_all_layers(s)),
spec!("select.deselectLayers", "Deselect Layers", &["Select"], None, "{}", has_doc, |s, _| {
+16 -1
View File
@@ -406,6 +406,21 @@ impl Default for ToolState {
}
}
/// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options, as Photoshop
/// copies them (Paste Layer Style puts them all on each target).
#[derive(Clone, Debug)]
pub struct CopiedStyle {
pub effects: photocraft_doc::Effects,
pub blend: photocraft_color::BlendMode,
pub fill: f32,
pub advanced: photocraft_doc::AdvancedBlending,
pub blend_if: photocraft_doc::BlendIf,
pub excluded_channels: u32,
/// The colour mode it was copied in: Blend If's channel ranges and the channel restrictions
/// only carry over to a document in the same mode.
pub mode: photocraft_color::ColorMode,
}
#[derive(Default)]
pub struct Session {
docs: Vec<DocState>,
@@ -419,7 +434,7 @@ pub struct Session {
pub clipboard: Option<edit_cmds::Clip>,
/// Layer › Layer Style › Copy Layer Style: effects, blend mode, fill opacity and the Advanced
/// Blending switches.
pub style_clipboard: Option<(photocraft_doc::Effects, photocraft_color::BlendMode, f32, photocraft_doc::AdvancedBlending)>,
pub style_clipboard: Option<CopiedStyle>,
/// Shape path context menu: copied vector fill and stroke styles.
pub path_fill_clipboard: Option<photocraft_doc::Fill>,
pub path_stroke_clipboard: Option<photocraft_doc::ShapeStroke>,
+5
View File
@@ -415,5 +415,10 @@ pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer {
// Merge Down replaces the lower layer, so keep its editing restrictions.
// In particular, a merged Background must remain opaque and position-locked.
merged.locks = lower.locks;
// And its place: two layers clipped in one chain merge into a layer still clipped to the base,
// with the lower layer's colour label and link.
merged.clipped = lower.clipped && upper.clipped;
merged.label = lower.label;
merged.link_group = lower.link_group;
merged
}
+75
View File
@@ -0,0 +1,75 @@
//! Clip groups through Release Clipping Mask, Merge Down, Merge Layers and Delete Hidden Layers,
//! as in Photoshop (layers audit L2). The stack: Background, B, C1*, C2* (C1 and C2 clipped to B).
use photocraft_engine::Session;
use serde_json::json;
fn stack() -> (Session, [u64; 3]) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap();
let mut ids = [0u64; 3];
for (i, color) in ["#ff0000", "#00ff00", "#0000ff"].iter().enumerate() {
ids[i] = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("select.rect", json!({"x": 4 * i, "y": 4, "width": 12, "height": 12})).unwrap();
s.execute("edit.fill", json!({"color": color})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
}
for c in &ids[1..] {
s.execute("layer.setProps", json!({"layer": c, "clipped": true})).unwrap();
}
(s, ids)
}
fn clipped(s: &Session, id: u64) -> bool {
s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clipped
}
#[test]
fn releasing_a_middle_layer_releases_the_ones_above_it() {
let (mut s, [_, c1, c2]) = stack();
let r = s.execute("layer.releaseClippingMask", json!({"layer": c1})).unwrap();
assert!(!clipped(&s, c1) && !clipped(&s, c2), "C2 isn't left clipped to the released C1: {r}");
// Releasing the top one leaves the one below it alone.
let (mut s, [_, c1, c2]) = stack();
s.execute("layer.releaseClippingMask", json!({"layer": c2})).unwrap();
assert!(clipped(&s, c1) && !clipped(&s, c2));
}
#[test]
fn merging_clipped_layers_keeps_the_result_clipped() {
let (mut s, [_, c1, c2]) = stack();
s.execute("layer.select", json!({"layer": c2})).unwrap();
let r = s.execute("layer.mergeDown", json!({})).unwrap();
let merged = r["layer"].as_u64().unwrap_or(c1);
assert!(clipped(&s, merged), "C2 merged down into C1 stays clipped to B");
let (mut s, [_, c1, c2]) = stack();
s.execute("layer.select", json!({"layer": c1})).unwrap();
s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap();
let r = s.execute("layer.mergeLayers", json!({})).unwrap();
assert!(clipped(&s, r["layer"].as_u64().unwrap()), "C1 + C2 merged stay clipped");
// Clipped layers of two chains (Background, B, C1*, E, D*: C1 clipped to B, D to E) merge
// into an ordinary layer: kept clipped at D's slot, C1's pixels would clip to E.
let (mut s, [_, c1, e]) = stack();
s.execute("layer.setProps", json!({"layer": e, "clipped": false})).unwrap();
let d = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.fill", json!({"color": "#808080"})).unwrap();
s.execute("layer.setProps", json!({"layer": d, "clipped": true})).unwrap();
s.execute("layer.select", json!({"layer": c1})).unwrap();
s.execute("layer.select", json!({"layer": d, "mode": "add"})).unwrap();
let r = s.execute("layer.mergeLayers", json!({})).unwrap();
assert!(!clipped(&s, r["layer"].as_u64().unwrap()), "C1 + D come from two clip chains");
// Merging the base with its clipped layers gives an ordinary layer.
let (mut s, [b, _, c2]) = stack();
s.execute("layer.select", json!({"layer": b})).unwrap();
s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap();
let r = s.execute("layer.mergeLayers", json!({})).unwrap();
assert!(!clipped(&s, r["layer"].as_u64().unwrap()));
}
#[test]
fn deleting_a_hidden_base_releases_its_clipped_layers() {
let (mut s, [b, c1, c2]) = stack();
s.execute("layer.setProps", json!({"layer": b, "visible": false})).unwrap();
s.execute("layer.delete.hiddenLayers", json!({})).unwrap();
assert!(!clipped(&s, c1) && !clipped(&s, c2), "they don't clip to the layer below the deleted base");
}
+99
View File
@@ -0,0 +1,99 @@
//! Copy / Paste Layer Style carries the whole style, Blending Options included (Blend If and the
//! channel restrictions); align with an active selection aligns to it however many layers are
//! selected (layers audit L2).
use photocraft_engine::Session;
use serde_json::json;
fn layer(s: &Session, id: u64) -> photocraft_doc::Layer {
s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone()
}
fn new_doc(s: &mut Session, mode: &str) -> (u64, u64) {
s.execute("file.new", json!({"width": 64, "height": 64, "mode": mode, "background": "white"})).unwrap();
let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
(a, b)
}
#[test]
fn paste_layer_style_carries_blend_if_and_channels() {
let mut s = Session::new();
let (a, b) = new_doc(&mut s, "rgb");
s.execute(
"layer.layerStyle.blendingOptions",
json!({"layer": a, "blendIf": [{"channel": "gray", "underlying": [0, 128]}, {"channel": "red", "thisLayer": [10, 200]}]}),
)
.unwrap();
s.execute("layer.setProps", json!({"layer": a, "channels": [true, false, true]})).unwrap();
s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap();
s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": b})).unwrap();
let (la, lb) = (layer(&s, a), layer(&s, b));
assert_eq!(lb.blend_if, la.blend_if, "Blend If, Gray and channel ranges");
assert_eq!(lb.excluded_channels, la.excluded_channels, "the unchecked G channel");
// Into a CMYK document: only the Gray range carries over; channels reset to all.
let (c, _) = new_doc(&mut s, "cmyk");
s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": c})).unwrap();
let lc = layer(&s, c);
assert_eq!(lc.blend_if.get(0), la.blend_if.get(0));
assert_eq!(lc.blend_if.ranges.len(), 1, "no RGB channel range on CMYK");
assert_eq!(lc.excluded_channels, 0);
}
#[test]
fn paste_layer_style_into_another_mode_keeps_blend_if_normalised() {
// A full Gray range and only a Red range: in CMYK nothing is left, so it is the default
// (which compares equal, so the paste isn't seen as a Blend If change).
let mut s = Session::new();
let (a, _) = new_doc(&mut s, "rgb");
s.execute("layer.layerStyle.blendingOptions", json!({"layer": a, "blendIf": [{"channel": "red", "thisLayer": [10, 200]}]})).unwrap();
s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap();
let (c, _) = new_doc(&mut s, "cmyk");
s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": c})).unwrap();
assert_eq!(layer(&s, c).blend_if, photocraft_doc::BlendIf::default());
}
#[test]
fn paste_layer_style_on_several_layers_is_one_undo_step() {
// 16-bit, onto a pixel layer and a group: one History step, and one Undo restores both.
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 64, "depth": 16, "background": "white"})).unwrap();
let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.layerStyle.blendingOptions", json!({"layer": a, "blendIf": [{"channel": "gray", "underlying": [0, 128]}]})).unwrap();
s.execute("layer.setProps", json!({"layer": a, "fill": 0.5, "blend": "multiply"})).unwrap();
s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap();
let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap();
let before = (layer(&s, b), layer(&s, g));
s.execute("layer.select", json!({"layer": b})).unwrap();
s.execute("layer.select", json!({"layer": g, "mode": "add"})).unwrap();
s.execute("layer.layerStyle.pasteLayerStyle", json!({})).unwrap();
let la = layer(&s, a);
for id in [b, g] {
let l = layer(&s, id);
assert_eq!((l.blend_if.clone(), l.fill_opacity, l.blend), (la.blend_if.clone(), la.fill_opacity, la.blend), "layer {id}");
}
s.execute("edit.undo", json!({})).unwrap();
let after = (layer(&s, b), layer(&s, g));
assert_eq!((after.0.blend_if, after.0.blend, after.1.blend_if, after.1.blend), (before.0.blend_if, before.0.blend, before.1.blend_if, before.1.blend));
assert_eq!((after.0.fill_opacity, after.1.fill_opacity), (before.0.fill_opacity, before.1.fill_opacity));
}
#[test]
fn several_layers_align_to_an_active_selection() {
let mut s = Session::new();
let (a, b) = new_doc(&mut s, "rgb");
for (id, x) in [(a, 4), (b, 30)] {
s.execute("layer.select", json!({"layer": id})).unwrap();
s.execute("select.rect", json!({"x": x, "y": 10, "width": 8, "height": 8})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
}
s.execute("select.rect", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap();
s.execute("layer.select", json!({"layer": a})).unwrap();
s.execute("layer.select", json!({"layer": b, "mode": "add"})).unwrap();
s.execute("layer.align.leftEdges", json!({})).unwrap();
for id in [a, b] {
let x0 = layer(&s, id).surface().unwrap().content_bounds().x0;
assert_eq!(x0, 40, "layer {id} aligned to the selection's left edge");
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ flate2 = { workspace = true }
# Same IEEE CRC-32 the ZIP container needs; already in Cargo.lock through flate2.
crc32fast = "1.5"
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
ruzstd = "0.9"
ruzstd = "0.9.1"
# Unix directory cleanup uses no-follow descriptor-relative operations; the same
# dependency provides the plain-fsync fallback on Apple file systems.