Merge pull request #10204 from FredApps/integration/approved-bugfix-parity-2026-10-29

Integrate 87 reviewed bugfix and Photoshop-parity PRs
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-10-30 08:06:22 -04:00
commit b1cfff9d30
380 files changed
+61739 -4338

No files matched your search

+4 -4
View File
@@ -19,11 +19,11 @@ gpu = { max-threads = 4 }
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))'
test-group = 'gpu'
# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure
# at the end of the log. The default profile's GPU override still applies.
# CI (`--profile ci`, .github/workflows/ci.yml). Collect all failures in one integration run
# instead of exposing one regression per rebuild. A hanging test still terminates, and every
# failure is repeated at the end of the log. The default profile's GPU override still applies.
[profile.ci]
fail-fast = true
fail-fast = false
slow-timeout = { period = "60s", terminate-after = 10 }
failure-output = "immediate-final"
+2 -1
View File
@@ -34,7 +34,7 @@ next to them.
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
| `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`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.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`, `align-edge-*.svg` and `distribute-*.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) |
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.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`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (114 files; `star-filled.svg` is `star.svg` with a solid fill, `rotate-ccw.svg` is `rotate-cw.svg` mirrored) | 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) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -44,6 +44,7 @@ next to them.
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/path-op-{new-layer,combine,subtract,intersect,exclude,merge}.svg` | Path Operations glyphs (Pen options bar): a single square for New Layer, and two overlapping squares showing the area each operation keeps (union, back minus front, overlap, everything but the overlap, the merged outline), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/text-warp.svg` | Create Warped Text glyph (Type tool options bar): a T above a bowed arc, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
Generated
+2
View File
@@ -5098,6 +5098,7 @@ dependencies = [
"image",
"image-webp",
"jpeg-encoder",
"jxl-oxide",
"photocraft-heif",
"png 0.18.1",
"proptest",
@@ -5107,6 +5108,7 @@ dependencies = [
"tiff",
"zune-core 0.5.3",
"zune-jpeg 0.5.15",
"zune-jpegxl",
]
[[package]]
+2
View File
@@ -286,6 +286,8 @@ The Flatpak prints through the host's CUPS socket (File › Print talks IPP to i
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
To use the bundled CLI, run `./photocraft-<version>-linux-x86_64.AppImage --cli --help`, or `--cli mcp` to start the MCP server on stdio. CLI launches skip desktop integration. A symlink named `photocraft-cli` also selects the CLI; see [`docs/development.md`](docs/development.md#headless-cli).
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072).
On macOS, the command-line tool comes as `photocraft-cli-<version>-macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
+11 -1
View File
@@ -15,7 +15,7 @@
//! be stamped on the shortcut, or the taskbar would stop matching the two.
/// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render
/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes.
/// on Apple's icon grid; elsewhere the 256 px hicolor render of the full tile.
pub fn window_icon() -> egui::IconData {
match eframe::icon_data::from_png_bytes(PNG) {
Ok(icon) => icon,
@@ -108,6 +108,16 @@ mod tests {
assert!(icon.rgba.chunks(4).any(|p| p[3] > 0), "the icon isn't blank");
}
#[test]
fn windows_and_linux_icons_keep_the_round_corners() {
// rx=112 of 512 leaves (10, 10) of a 256 px render transparent; the old crop filled it (#4478).
let png = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png");
let icon = eframe::icon_data::from_png_bytes(png).unwrap();
assert_eq!(icon.width, 256);
assert_eq!(icon.rgba[(10 * 256 + 10) * 4 + 3], 0, "corner is transparent");
assert_eq!(icon.rgba[(128 * 256 + 2) * 4 + 3], 255, "the tile is full bleed");
}
/// The `.ico` build.rs embeds: every size Windows asks for, each a valid PNG or BMP image.
#[test]
fn exe_icon_has_every_shell_size() {
+27 -8
View File
@@ -16,15 +16,10 @@ use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::REPLY_DEADLINE;
use serde_json::{Value, json};
pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
/// Start serving a listener already bound during startup, before a window is opened.
pub fn start(listener: TcpListener, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("photocraft: control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
let port = listener.local_addr().map_or(0, |a| a.port());
eprintln!("photocraft: control server listening on {}", listening_addr(&listener, port));
std::thread::spawn(move || {
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
@@ -152,6 +147,30 @@ mod tests {
TcpStream::connect(&logged).unwrap();
}
#[test]
fn a_prebound_listener_authenticates_and_dispatches_requests() {
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let requests = start(listener, TOKEN.into(), egui::Context::default());
let mut stream = TcpStream::connect(address).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut replies = BufReader::new(stream.try_clone().unwrap());
writeln!(stream, "{}", json!({"id": 1, "method": "auth", "params": {"token": TOKEN}})).unwrap();
let mut line = String::new();
replies.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["result"]["authenticated"], true);
writeln!(stream, "{}", json!({"id": 2, "method": "test.bound"})).unwrap();
let request = requests.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(request.method, "test.bound");
request.reply.send(json!({"ok": true, "result": "served"})).unwrap();
line.clear();
replies.read_line(&mut line).unwrap();
let reply: Value = serde_json::from_str(&line).unwrap();
assert_eq!(reply["id"], 2);
assert_eq!(reply["result"], "served");
}
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
/// (releasing its connection permit), and nothing it sent is dispatched.
#[test]
+25 -1
View File
@@ -315,22 +315,31 @@ pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
/// The file [`AppLogger::attach_dir`] opened, so a failure can tell the user what to attach.
path: Mutex<Option<PathBuf>>,
/// The lock that marks the attached log file as in use (see [`rotate`]); held until exit.
owner: Mutex<Option<File>>,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), owner: Mutex::new(None) }
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), path: Mutex::new(None), owner: Mutex::new(None) }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
let LogFile { path, file, owner } = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
*self.path.lock().unwrap_or_else(PoisonError::into_inner) = Some(path.clone());
*self.owner.lock().unwrap_or_else(PoisonError::into_inner) = owner;
Ok(path)
}
/// The log file records are written to, once [`AppLogger::attach_dir`] opened one: `None` when
/// the file could not be created (standard error is the only sink, see [`install`]).
pub fn path(&self) -> Option<PathBuf> {
self.path.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
}
impl log::Log for AppLogger {
@@ -379,6 +388,21 @@ mod tests {
d
}
/// The startup-failure dialog names the log file, so the logger has to remember it (#7643).
/// A start that couldn't create one keeps `None` and the dialog leaves the line out.
#[test]
fn path_names_the_attached_log_file() {
let _spawns = crate::no_child_spawns();
let dir = temp_dir("path");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
assert_eq!(logger.path(), None, "nothing is written before a file is attached");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(logger.path(), Some(path));
drop(logger);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_default_filter_shows_photocraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
+29 -5
View File
@@ -66,6 +66,7 @@ mod services;
// macOS gets one instance from LaunchServices (open-documents Apple events).
#[cfg(any(not(target_os = "macos"), test))]
mod single_instance;
mod startup_report;
// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
mod tablet;
mod ui_state;
@@ -285,12 +286,19 @@ fn control_setup(
cli_port: Option<u16>,
auth: photocraft_automation::args::ControlArgs,
automation: &photocraft_automation::args::AutomationArgs,
) -> Option<(u16, String, photocraft_automation::workspace::AuthorizedWorkspace)> {
) -> Option<(std::net::TcpListener, String, photocraft_automation::workspace::AuthorizedWorkspace)> {
let (port, warning) = control_port_for(cli_port, &integrations(services::prefs_file().as_deref()));
if let Some(warning) = &warning {
eprint!("{warning}");
}
let port = port?;
let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(listener) => listener,
Err(error) => {
eprintln!("photocraft: control server failed to bind 127.0.0.1:{port}: {error}");
std::process::exit(2);
}
};
let (supplied, token_file) = (auth.control_token, auth.control_token_file);
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
Ok(token) => token,
@@ -313,7 +321,7 @@ fn control_setup(
std::process::exit(2);
}
};
Some((port, token, workspace))
Some((listener, token, workspace))
}
/// Record this start's backend in the GPU startup marker, if the marker is locked.
@@ -385,12 +393,14 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
let _ = (new_instance, &launch_options);
// Bind and authenticate the authorized control listener before display/GPU initialization.
let control = control_setup(control_port, auth, &automation);
#[cfg(target_os = "linux")]
let native_session = linux_libs::session_from_env(|name| std::env::var(name).ok());
#[cfg(target_os = "linux")]
let session = linux_display_session();
let control = control_setup(control_port, auth, &automation);
// Finder / Dock / Open With deliver files as Apple events, not arguments; catch the one that
// launched us as well as later ones. Lives until the event loop returns.
#[cfg(target_os = "macos")]
@@ -473,6 +483,10 @@ fn main() -> eframe::Result {
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
let keep_marker = gpu_startup::keep_marker_after_error(&plan, os);
// For the failure dialog below: the plan itself moves into the window closure. With
// `WGPU_BACKEND` set (`plan.env`) wgpu picks the backend, so name that rather than `auto`.
let started_renderer = plan.env.clone().unwrap_or_else(|| plan.backend.name().to_string());
let started_note = plan.reason.clone();
let app_created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let created_in_callback = app_created.clone();
let started_sentinel = sentinel.clone();
@@ -599,8 +613,8 @@ fn main() -> eframe::Result {
log::warn!("couldn't remember GPU backend {}: {e}", b.name());
}
});
if let Some((port, token, _)) = control {
let rx = control_server::start(port, token, cc.egui_ctx.clone());
if let Some((listener, token, _)) = control {
let rx = control_server::start(listener, token, cc.egui_ctx.clone());
app = app.with_control(rx);
}
#[cfg(target_os = "macos")]
@@ -710,6 +724,16 @@ fn main() -> eframe::Result {
}
}
}
// A launch that fails before a window exists leaves nothing on screen, and a desktop launch
// has no console for the `Err` above: log it, then say why in a native dialog with the log
// file to attach (#7643). A terminal or automation launch can read standard error or the
// exit status instead, so it keeps the silent exit.
if let Err(error) = &result {
log::error!("PhotoCraft could not start: {error}");
if startup_report::needs_dialog(control_port.is_some()) {
startup_report::show(&error.to_string(), &started_renderer, started_note.as_deref(), logger.and_then(logging::AppLogger::path).as_deref());
}
}
result
}
+1
View File
@@ -92,6 +92,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("OpenRaster", &["ora"]),
("PNG", &["png"]),
("JPEG", &["jpg", "jpeg"]),
("JPEG XL", &["jxl"]),
("WebP", &["webp"]),
("TIFF", &["tif", "tiff"]),
("Targa", &["tga"]),
+7 -2
View File
@@ -312,10 +312,15 @@ fn save_as_offers_only_writable_formats_including_ico() {
let subtype = name.rsplit('.').next().unwrap_or_default();
assert!(!filters.iter().any(|(_, exts)| exts.iter().any(|e| e == subtype)), "{name}: .{subtype} is offered");
}
// File › Open lists every file Save As writes, also in that format's own type (PDF is written
// but not read yet).
// File Open lists readable Save As formats in their own type. Export-only formats must
// stay absent from both Open filters rather than advertising an unavailable decoder.
for (name, exts) in SAVE_FILTERS {
for ext in exts.iter().filter(|e| **e != "pdf") {
if photocraft_codecs::from_extension(ext).is_some_and(|format| !format.caps().read) {
assert!(!OPEN_EXTS.contains(ext), "{name}: export-only .{ext} must not appear in All Formats");
assert!(!OPEN_FILTERS.iter().any(|(_, open)| open.contains(ext)), "{name}: export-only .{ext} must not appear in an Open type");
continue;
}
assert!(OPEN_EXTS.contains(ext), "{name}: Open's All Formats lacks .{ext}");
assert!(OPEN_FILTERS.iter().any(|(_, open)| open.contains(ext)), "{name}: no Open type lists .{ext}");
}
+104
View File
@@ -0,0 +1,104 @@
//! Telling the user why a launch failed when there is no window to say it in (#7643).
//!
//! A desktop launch has no console — release builds are GUI-subsystem apps on Windows — so the
//! `Err` that ends `main` used to vanish and PhotoCraft "just closed". This shows one native
//! dialog with the error, the renderer that was tried and the log file to attach. The text is
//! English diagnostics, like Help › System Info, so a bug report reads the same in every language.
//!
//! Terminal and automation launches keep the old behaviour: they can read standard error or the
//! exit status, and a modal dialog would leave a script waiting.
use std::io::IsTerminal;
use std::path::Path;
/// Whether a failed launch needs the dialog: no terminal under standard error, no control client
/// (an agent launch reads the exit status instead of clicking OK), and a screen to put it on.
pub fn dialog_wanted(stderr_is_terminal: bool, automation: bool, display: bool) -> bool {
!stderr_is_terminal && !automation && display
}
/// Whether this launch asks for the dialog (see [`dialog_wanted`]).
pub fn needs_dialog(automation: bool) -> bool {
dialog_wanted(std::io::stderr().is_terminal(), automation, has_display())
}
/// Whether there is a screen for a dialog: always on Windows and macOS; on Linux a headless box
/// (a CI runner, an SSH session — `startup_without_display` removes both variables) has none.
fn has_display() -> bool {
#[cfg(target_os = "linux")]
{
["DISPLAY", "WAYLAND_DISPLAY"].iter().any(|key| std::env::var_os(key).is_some_and(|v| !v.is_empty()))
}
#[cfg(not(target_os = "linux"))]
{
true
}
}
/// The dialog's text: the error, the renderer that was tried and why it wasn't the preference,
/// the version and, when one was opened, the log file.
pub fn text(error: &str, renderer: &str, note: Option<&str>, version: &str, log: Option<&Path>) -> String {
let mut text = format!("PhotoCraft could not start.\n\n{error}\n\nRenderer: {renderer}");
if let Some(note) = note {
text.push_str(&format!("\nStartup: {note}"));
}
text.push_str(&format!("\nVersion: {version}"));
if let Some(path) = log {
text.push_str(&format!("\nLog file: {}", path.display()));
}
text
}
/// Show [`text`] in a native error dialog. Called after the event loop returned, so nothing is
/// repainting and the launch was about to fail anyway.
pub fn show(error: &str, renderer: &str, note: Option<&str>, log: Option<&Path>) {
let version = photocraft_engine::build_info::long_version();
let body = text(error, renderer, note, &version, log);
log::info!("showing the startup-failure dialog");
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title(photocraft_ui_egui::i18n::t("PhotoCraft could not start"))
.set_description(body)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_desktop_launch_asks_the_user_and_a_script_does_not() {
assert!(dialog_wanted(false, false, true), "no console: the error would vanish");
assert!(!dialog_wanted(true, false, true), "a terminal shows the error itself");
assert!(!dialog_wanted(false, true, true), "an automation client reads the exit status");
assert!(!dialog_wanted(false, false, false), "a headless launch has no screen for a dialog");
}
#[test]
fn the_text_names_the_error_renderer_version_and_log() {
let log = Path::new("/home/me/.config/photocraft/logs/photocraft.log");
let body = text("no adapter for Vulkan", "dx12", None, "0.15.1 (abc1234)", Some(log));
for part in [
"could not start",
"no adapter for Vulkan",
"Renderer: dx12",
"Version: 0.15.1 (abc1234)",
"Log file: /home/me/.config/photocraft/logs/photocraft.log",
] {
assert!(body.contains(part), "{part:?} missing from {body:?}");
}
assert!(!body.contains("Startup:"), "no fallback to explain: {body:?}");
// A start whose log file could not be created says so by leaving the line out.
assert!(!text("boom", "cpu", None, "0.15.1", None).contains("Log file:"));
}
/// A crash fallback is the confusing case: the app picks a renderer the user never chose, so
/// the dialog has to say why as well as which.
#[test]
fn a_fallback_that_was_not_the_preference_is_explained() {
let note = "the previous start didn't finish on vulkan; using dx12";
let body = text("device lost", "dx12", Some(note), "0.15.1", None);
assert!(body.contains(&format!("Renderer: dx12\nStartup: {note}\n")), "{body:?}");
}
}
+29 -4
View File
@@ -2,8 +2,7 @@
//! status 0 believes the control server is running when it is not.
//!
//! These tests run the real binary as a subprocess and assert the exit code,
//! covering both failure paths in `main.rs`: control authentication and the
//! automation workspace.
//! covering control authentication, the automation workspace and listener binding.
/// A syntactically valid 64-hex-char token, so the workspace test reaches the
/// workspace check instead of failing on token validation first.
@@ -28,7 +27,7 @@ fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run {
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("photocraft-control-exit-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let output = std::process::Command::new(crate::binary())
let mut child = std::process::Command::new(crate::binary())
.args(args)
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
// Isolate from the host environment so the test is reproducible.
@@ -41,12 +40,38 @@ fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run {
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.envs(vars.iter().copied())
.output()
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("failed to run photocraft binary");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let timed_out = loop {
if child.try_wait().unwrap().is_some() {
break false;
}
if std::time::Instant::now() >= deadline {
child.kill().unwrap();
break true;
}
std::thread::sleep(std::time::Duration::from_millis(20));
};
let output = child.wait_with_output().unwrap();
let gpu_started = dir.join("gpu-starting.json").exists();
std::fs::remove_dir_all(&dir).ok();
assert!(!timed_out, "PhotoCraft did not exit: {}", String::from_utf8_lossy(&output.stderr));
assert!(!gpu_started, "control setup errors must exit before GPU startup");
Run { status: output.status, stderr: String::from_utf8_lossy(&output.stderr).into_owned() }
}
#[test]
fn an_occupied_control_port_exits_with_status_2() {
let held = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = held.local_addr().unwrap().port().to_string();
let Run { status, stderr } = run_photocraft(&["--control", &port, "--control-token", VALID_TOKEN]);
assert_eq!(status.code(), Some(2), "occupied control port must exit 2, stderr: {stderr}");
assert!(stderr.contains(&format!("failed to bind 127.0.0.1:{port}")), "stderr: {stderr}");
}
#[test]
fn invalid_control_token_exits_with_status_2() {
// `validate_token` rejects a token that is not exactly 64 hex characters.
+2 -2
View File
@@ -17,8 +17,8 @@ Exactly three colours:
## Geometry
A 512-unit tile (`viewBox="0 0 512 512"`), rounded square with `rx=112`, no border. The macOS
renders pad it onto Apple's 824/1024 icon grid; the Windows and Linux renders crop 22 units off
each side so the figure reads at 16–48 px.
renders pad it onto Apple's 824/1024 icon grid; the Windows and Linux renders use the whole tile,
as the other Crafting Apps do.
## Provenance
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 B

After

Width:  |  Height:  |  Size: 812 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 90 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 3h12v6h6v12H9v-6H3z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 266 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 3h12v12H3zM9 9h12v12H9z" fill="currentColor" fill-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 290 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="12" height="12" />
<rect x="9" y="9" width="12" height="12" />
<path d="M9 9h6v6H9z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 3h12v6h6v12H9v-6H3z" />
</svg>

After

Width:  |  Height:  |  Size: 246 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="4" y="4" width="16" height="16" rx="1" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 281 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 3h12v6H9v6H3z" fill="currentColor" />
<rect x="9" y="9" width="12" height="12" />
</svg>

After

Width:  |  Height:  |  Size: 306 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9c-2.52 0-4.93 1-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 296 B

+13 -1
View File
@@ -1,11 +1,23 @@
# Raster formats
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate. Lossless JPEG XL (.jxl) export uses the pure-Rust `zune-jpegxl` encoder for 8/16-bit gray, RGB and RGBA data; gray+alpha is expanded to RGBA at the same bit depth. JPEG XL import is tracked separately in #2868.
## Layered TIFF
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: up to sixteen extra samples (spot colours and alpha channels, plus a Quick Mask) are written in the IFD and round-trip, with Photoshop image resources for the channel name, ink colour and solidity; Lab documents are saved flat, with their extra samples. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
## JPEG XL
Save As, Export As and the CLI can write a lossless, single-image `.jxl` bare
codestream at 8 or 16 bits per sample. Gray, RGB and RGBA are encoded
natively. Grayscale with alpha is expanded to RGBA at the original bit depth
to preserve both grayscale and alpha samples. For 16-bit RGBA the encoder's
incorrect default 8-bit alpha header is corrected to explicitly describe
16-bit alpha, with an independent decoder oracle checking alpha values.
The bare codestream has no ICC, EXIF, XMP or DPI boxes; the export path
converts tagged RGB to sRGB and surfaces metadata losses as fidelity warnings.
JPEG XL opening remains under review in #2868.
## WebP
Lossless WebP (VP8L) is encoded and decoded by `image-webp`. Lossy WebP is written by PhotoCraft's own VP8 key-frame encoder in `crates/codecs/src/codecs/vp8`, a clean-room implementation of RFC 6386: BT.601 4:2:0 conversion, 16×16 and 4×4 intra prediction chosen per macroblock by reconstruction error, the forward DCT and Walsh-Hadamard transforms paired with the bit-exact inverse transforms, quantisation on the RFC's `dc_qlookup`/`ac_qlookup` tables, token coding with per-frame probability updates, and skipped macroblocks for flat areas. `EncodeOptions::webp_lossless` picks the encoder and `webp_quality` (0–100, the libwebp scale) sets the quantiser; alpha travels losslessly in an `ALPH` chunk and ICC/EXIF/XMP in their own chunks of the extended (`VP8X`) container. The decoder applies the frame's loop filter; no C library is involved on either side.
+1 -4
View File
@@ -23,10 +23,7 @@ impl Stream {
/// The raw slot count of an object's array field (including null slots), for charging
/// traversal work before nulls are filtered out. `None` when the field is not an array.
pub fn array_len(&self, id: ObjId, tag: &[u8; 4]) -> Option<usize> {
match self.field(id, tag)? {
Value::Array(a) => Some(a.len()),
_ => None,
}
self.field(id, tag)?.array_len()
}
/// A child object (shared, linked or inline); `None` for null or missing.
+23 -15
View File
@@ -153,8 +153,8 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
let fields: Vec<&Value> = s.positional(c).collect();
// The corner arrays are scanned for every occurrence of a shared curve, so charge
// them like records before they are walked.
if let Some(Value::Array(idx)) = fields.get(1)
&& let Err(reason) = r.geometry_budget.records(idx.len())
if let Some(len) = fields.get(1).and_then(|v| v.array_len())
&& let Err(reason) = r.geometry_budget.records(len)
{
r.warn(reason);
return None;
@@ -170,17 +170,23 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
Value::Bool(b) => *b,
_ => return None,
};
let Value::Array(records) = fields.next()? else { return None };
seen = seen.checked_add(records.len())?;
let records = fields.next()?;
let len = records.array_len()?;
seen = seen.checked_add(len)?;
if seen > MAX_NODES {
r.warn("curves with more than four million nodes");
return None;
}
if let Err(reason) = r.geometry_budget.records(records.len()) {
if let Err(reason) = r.geometry_budget.records(len) {
r.warn(reason);
return None;
}
let anchors = anchors(records)?;
let anchors = match records {
Value::Array(a) => anchors(a.iter().map(record)),
Value::Records { size: 18, data } => anchors(data.as_chunks::<18>().0.iter().map(record18)),
// Records of another width are no curve nodes.
_ => anchors(std::iter::repeat_n(None, len)),
}?;
let radii = if index == 0 { corners.as_ref() } else { None };
if let Some(sp) = subpath(&anchors, closed, radii) {
path.subpaths.push(sp);
@@ -202,22 +208,24 @@ struct Anchor {
fn record(v: &Value) -> Option<(Point, u8)> {
let Value::Bytes(b) = v else { return None };
if b.len() != 18 {
return None;
}
let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?);
let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?);
record18(b.as_slice().try_into().ok()?)
}
/// One 18-byte curve node: f64 x, f64 y, u8 kind, u8 role.
fn record18(b: &[u8; 18]) -> Option<(Point, u8)> {
let x = f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]);
let y = f64::from_le_bytes([b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]]);
if !x.is_finite() || !y.is_finite() {
return None;
}
Some((Point { x, y }, *b.get(17)?))
Some((Point { x, y }, b[17]))
}
fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
fn anchors(records: impl ExactSizeIterator<Item = Option<(Point, u8)>>) -> Option<Vec<Anchor>> {
let mut out: Vec<Anchor> = Vec::with_capacity(records.len() / 3 + 1);
let mut pending_in = None;
for (i, v) in records.iter().enumerate() {
let (p, role) = record(v)?;
for (i, v) in records.enumerate() {
let (p, role) = v?;
match role {
0 => {
out.push(Anchor { p, inh: pending_in.take(), out: None, record: i });
+3
View File
@@ -300,6 +300,9 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
// The parsed stream owns copies of everything it needs: do not hold the inflated bytes
// (hundreds of MB for a large vector document) while the model is built.
drop(doc);
Reader::new(&s, &mut archive, limits).document()
}
+9 -1
View File
@@ -129,11 +129,16 @@ const MAX_STOPS: usize = 1024;
fn floats(v: &Value) -> Option<Vec<f64>> {
match v {
Value::Floats(f) => Some(f.clone()),
Value::Bytes(b) if b.len() % 4 == 0 => Some(b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()),
Value::Bytes(b) if b.len() % 4 == 0 => Some(f32_samples(b)),
_ => None,
}
}
/// Little-endian `f32` samples widened to `f64`.
fn f32_samples(b: &[u8]) -> Vec<f64> {
b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()
}
/// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB
/// equivalent, else its base colour, which in real files hold plain colours. Colours are shared
/// objects, so one can link to itself, and with both links followed at every level a colour read
@@ -241,6 +246,9 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
let cols = s.objs(grad, b"Cols");
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
Some(Value::Array(a)) => a.iter().filter_map(floats).take(MAX_STOPS).collect(),
Some(Value::Records { size, data }) if *size % 4 == 0 && *size > 0 => {
data.chunks_exact(usize::from(*size)).map(f32_samples).take(MAX_STOPS).collect()
}
_ => Vec::new(),
};
let mut stops = Vec::new();
+55 -25
View File
@@ -53,6 +53,23 @@ pub enum Value {
/// `None` is a null object.
Obj(Option<ObjId>),
Array(Vec<Value>),
/// An array of fixed-size records (`0x2c`, curve nodes) held in one buffer: `size` bytes each.
/// Counts as one value per record against [`MAX_VALUES`], like the array of `Bytes` it replaces.
Records {
size: u16,
data: Vec<u8>,
},
}
impl Value {
/// Slot count of an array field (including null slots); `None` for a scalar.
pub fn array_len(&self) -> Option<usize> {
match self {
Value::Array(a) => Some(a.len()),
Value::Records { size, data } => Some(data.len() / usize::from(*size).max(1)),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -73,39 +90,50 @@ pub struct Object {
pub chain: Vec<Tag>,
/// Fields in stream order; positional fields have tag 0.
pub fields: Vec<(Tag, Value)>,
/// First-occurrence index of each non-positional tag, so `get` is O(1). A shared object
/// can be referenced by many sibling layers, and each reference used to rescan all of its
/// fields; indexing once keeps repeated lookups from multiplying into quadratic work.
index: std::collections::HashMap<Tag, usize>,
/// Index for objects with more than [`SCAN`] fields: (tag, index of its first occurrence)
/// sorted by tag, so `get` is a binary search. A shared object can be referenced by many
/// sibling layers, and each reference used to rescan all of its fields; indexing once keeps
/// repeated lookups from multiplying into quadratic work. Smaller objects have no index
/// (and no per-object hash map to build): scanning a handful of fields is cheaper.
index: Vec<(Tag, u32)>,
}
/// Objects with at most this many fields are scanned instead of indexed.
const SCAN: usize = 12;
fn build_index(fields: &[(Tag, Value)]) -> Vec<(Tag, u32)> {
if fields.len() <= SCAN {
return Vec::new();
}
// Positional fields share tag 0 and are not looked up by tag. Sorting by (tag, index) and
// keeping the first of each tag preserves first-match semantics.
let mut index: Vec<(Tag, u32)> = fields.iter().zip(0u32..).filter(|((t, _), _)| t.0 != 0).map(|((t, _), i)| (*t, i)).collect();
index.sort_unstable();
index.dedup_by_key(|e| e.0);
index
}
impl Object {
pub fn new(class: Tag, kind: Kind, chain: Vec<Tag>, fields: Vec<(Tag, Value)>) -> Self {
let mut index = std::collections::HashMap::with_capacity(fields.len());
for (i, (t, _)) in fields.iter().enumerate() {
// Positional fields share tag 0 and are not looked up by tag; keep first-match
// semantics for real tags.
if t.0 != 0 {
index.entry(*t).or_insert(i);
}
}
let index = build_index(&fields);
Object { class, kind, chain, fields, index }
}
pub fn get(&self, tag: Tag) -> Option<&Value> {
self.index.get(&tag).map(|&i| &self.fields[i].1)
if tag.0 == 0 {
return None;
}
if self.fields.len() <= SCAN {
return self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v);
}
let at = self.index.binary_search_by_key(&tag, |e| e.0).ok()?;
self.index.get(at).and_then(|&(_, i)| self.fields.get(i as usize)).map(|(_, v)| v)
}
/// Replaces the fields (the root object is filled in after it is pushed) and rebuilds the
/// tag index so `get` stays correct.
fn set_fields(&mut self, fields: Vec<(Tag, Value)>) {
self.index.clear();
self.index.reserve(fields.len());
for (i, (t, _)) in fields.iter().enumerate() {
if t.0 != 0 {
self.index.entry(*t).or_insert(i);
}
}
self.index = build_index(&fields);
self.fields = fields;
}
}
@@ -336,11 +364,13 @@ impl Parser<'_> {
if size == 0 || n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
let mut v = self.vec(n)?;
for _ in 0..n {
v.push(Value::Bytes(self.c.take(size)?.to_vec()));
}
Ok(Value::Array(v))
// Still one value per record against the budget, but a single allocation.
self.spend(n)?;
let bytes = self.c.take(n * size)?;
let mut data = Vec::new();
data.try_reserve_exact(bytes.len()).map_err(|_| Error::Limit("not enough memory for the document stream"))?;
data.extend_from_slice(bytes);
Ok(Value::Records { size: size as u16, data })
}
0x32 => {
let n = self.count(1)?;
+39
View File
@@ -757,3 +757,42 @@ mod spot_colours {
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
}
}
/// Object lookup and record arrays against the structures they replaced.
mod stream_storage {
use crate::stream::{self, Kind, Object, Tag, Value};
use crate::synth::{self, F, tag};
/// The per-object map `Object::get` used before: first occurrence of each real tag.
fn reference(fields: &[(Tag, Value)], t: Tag) -> Option<&Value> {
let mut index = std::collections::HashMap::new();
for (i, (ft, _)) in fields.iter().enumerate() {
if ft.0 != 0 {
index.entry(*ft).or_insert(i);
}
}
index.get(&t).map(|&i| &fields[i].1)
}
#[test]
fn lookup_keeps_first_match_for_every_object_size() {
for n in 0..60u32 {
// Tags repeat (n % 7 distinct tags) and every fifth field is positional (tag 0).
let fields: Vec<(Tag, Value)> = (0..n).map(|i| (if i % 5 == 0 { Tag(0) } else { Tag(100 + i % 7) }, Value::UInt(u64::from(i)))).collect();
let object = Object::new(Tag(1), Kind::Inline, vec![Tag(1)], fields.clone());
for t in 0..120 {
assert_eq!(object.get(Tag(t)), reference(&fields, Tag(t)), "{n} fields, tag {t}");
}
}
}
#[test]
fn a_fixed_size_array_is_one_buffer_and_one_value_per_record() {
let records: Vec<u8> = (0..18 * 5).map(|i| i as u8).collect();
let bytes = synth::stream(&[(tag(b"Nods"), F::Records(18, records.chunks(18).map(<[u8]>::to_vec).collect()))]);
let s = stream::parse(&bytes).unwrap();
let root = s.object(s.root).unwrap();
assert_eq!(root.get(Tag::of(b"Nods")), Some(&Value::Records { size: 18, data: records }));
assert_eq!(root.get(Tag::of(b"Nods")).and_then(Value::array_len), Some(5));
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
_ => bounds.x0,
};
if !artistic {
if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) {
if s.objs(frame, b"ColW").len() > 1 || s.array_len(frame, b"ColW").is_some_and(|n| n > 1) {
r.warn("text frames with several columns (imported as one column)");
}
if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) {
+147 -14
View File
@@ -17,6 +17,7 @@ use photocraft_geom::Rect;
use crate::Ctx;
use crate::artistic::GalleryEffect;
use crate::artistic::GalleryFilter as F;
use crate::cutout;
use crate::fxutil::{box_blur_n, cell_point, gauss_blur_n, gauss_box_radii, rgba, set_rgba};
use crate::image::Image;
use crate::noise::hash01;
@@ -409,7 +410,8 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
let g = |k: &str| e.get(k);
match e.filter {
F::ColoredPencil => 2,
F::Cutout => gs(cutout_sigma(e)),
// Traced over the whole image before tiling (see [`Prepared`]).
F::Cutout => 0,
F::DryBrush | F::Fresco => kuwahara_reach(1 + g("brushSize") as usize) + gs(1.0) + 2,
F::FilmGrain | F::DiffuseGlow | F::ChalkCharcoal | F::Reticulation | F::Grain | F::MosaicTiles | F::Texturizer => 0,
F::RoughPastels => gs(0.6 + 1.2) + ((2.0 + g("strokeLength")) / 2.0) as i32 + 3,
@@ -451,8 +453,8 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
}
}
fn cutout_sigma(e: &GalleryEffect) -> f32 {
0.5 + e.get("edgeSimplicity") * (1.1 - 0.3 * e.get("edgeFidelity"))
fn cutout_settings(e: &GalleryEffect) -> cutout::Settings {
cutout::Settings { levels: e.get("numberOfLevels").round().clamp(2.0, 8.0) as usize, simplicity: e.get("edgeSimplicity"), fidelity: e.get("edgeFidelity") }
}
/// Halftone Pattern's screen spacing in pixels (about 3.5 at Size 1, 25.5 at 12).
fn halftone_cell(e: &GalleryEffect) -> f32 {
@@ -541,6 +543,54 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx)
for e in effects {
apply(e, &mut win, ctx);
}
finish(&win, src, out, ctx)
}
/// Whether a stack needs the whole image at once (Cutout traces it).
pub(crate) fn is_global(effects: &[GalleryEffect]) -> bool {
effects.iter().any(|e| e.filter == F::Cutout)
}
/// A stack with a Cutout, prepared once for all tiles: the effects up to the last Cutout run
/// over the whole image and it is traced; each tile then only fills in the traced shapes and
/// runs the effects after it.
pub(crate) struct Prepared {
traced: cutout::Traced,
rest: Vec<GalleryEffect>,
}
impl Prepared {
/// `None` for a stack that has no Cutout (it runs tile by tile as usual).
pub(crate) fn new(effects: &[GalleryEffect], src: &Image, ctx: &Ctx) -> Option<Prepared> {
let last = effects.iter().rposition(|e| e.filter == F::Cutout)?;
let r = src.rect;
let (w, h) = (r.width() as usize, r.height() as usize);
let mut win = Win { r, w, h, c: src.data.chunks_exact(src.ch).map(|px| rgb(rgba(ctx, px))).collect() };
for e in effects.get(..last).unwrap_or_default() {
apply(e, &mut win, ctx);
}
let traced = cutout::trace(&win.c, r, &cutout_settings(effects.get(last)?));
Some(Prepared { traced, rest: effects.get(last + 1..).unwrap_or_default().to_vec() })
}
/// The stack's result over `out` (`src` is the image it was prepared from).
pub(crate) fn run(&self, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let r = out.inflate(reach(&self.rest)).intersect(&src.rect);
let (w, h) = (r.width() as usize, r.height() as usize);
if w == 0 || h == 0 {
return src.crop(out);
}
let mut win = Win { r, w, h, c: self.traced.render(r) };
for e in &self.rest {
apply(e, &mut win, ctx);
}
finish(&win, src, out, ctx)
}
}
/// `out` of the source with its colour from `win` (alpha kept).
fn finish(win: &Win, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let n = src.ch;
let mut res = src.crop(out);
let ow = out.width() as usize;
for (i, px) in res.chunks_exact_mut(n).enumerate() {
@@ -585,16 +635,7 @@ fn apply_artistic(e: &GalleryEffect, win: &mut Win) -> bool {
win.c[i] = mix(paper, map(win.c[i], |v| v * 0.88), cov);
}
}
F::Cutout => {
blur3(&mut win.c, w, h, cutout_sigma(e));
let levels = g("numberOfLevels").round();
for p in &mut win.c {
// Flat tones per luminance level, keeping each region's hue.
let l = lum(p).max(1e-4);
let k = posterize(l, levels) / l;
*p = map(*p, |v| posterize(clamp01(v * k), levels * 2.0));
}
}
F::Cutout => win.c = cutout::trace(&win.c, win.r, &cutout_settings(e)).render(win.r),
F::DryBrush | F::Fresco => {
let fresco = e.filter == F::Fresco;
kuwahara(win, 1 + g("brushSize") as usize);
@@ -1344,7 +1385,7 @@ mod tests {
let fp = FilterParams::FilterGallery { effects: vec![e.clone()] };
apply_tiled(&s, &fp, b, b, None, tile, Some(b)).read_region(b)
};
for f in [G::RoughPastels, G::AngledStrokes] {
for f in [G::RoughPastels, G::AngledStrokes, G::Cutout] {
let base = run(&GalleryEffect::new(f), 256);
for prm in f.params() {
let crate::GalleryParamKind::Range { min, max, .. } = prm.kind else { continue };
@@ -1361,6 +1402,98 @@ mod tests {
}
}
/// Runs one Cutout over a grey image given as rows of values; returns the red channel.
fn cutout_on(rows: &[Vec<f32>], set: &[(&str, f32)]) -> Vec<Vec<f32>> {
let (w, h) = (rows[0].len() as i32, rows.len() as i32);
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
for (y, row) in rows.iter().enumerate() {
for (x, v) in row.iter().enumerate() {
s.write_pixel(x as i32, y as i32, &[*v, *v, *v, 1.0]);
}
}
let mut e = GalleryEffect::new(crate::GalleryFilter::Cutout);
for (k, v) in set {
e.set(k, *v);
}
let b = Rect::new(0, 0, w, h);
let out = apply_tiled(&s, &FilterParams::FilterGallery { effects: vec![e] }, b, b, None, 256, Some(b)).read_region(b);
out.chunks(4 * w as usize).map(|r| r.chunks(4).map(|p| p[0]).collect()).collect()
}
#[test]
fn cutout_makes_flat_shapes_with_clean_edges() {
// It used to posterize every pixel after a blur: noise survived as speckle, edges were
// ragged and smooth gradients banded instead of becoming flat paper.
let noisy: Vec<Vec<f32>> = (0..32).map(|y| (0..64).map(|x| if x < 32 { 0.2 } else { 0.8 } + (hash01(x, y, 0, 7) - 0.5) * 0.16).collect()).collect();
let out = cutout_on(&noisy, &[]);
for (x0, x1) in [(2, 28), (36, 62)] {
let vals: Vec<f32> = (2..30).flat_map(|y| out[y][x0..x1].to_vec()).collect();
let spread = vals.iter().fold(f32::MIN, |a, &v| a.max(v)) - vals.iter().fold(f32::MAX, |a, &v| a.min(v));
assert!(spread < 1e-4, "a noisy area should be one flat tone, spread {spread}");
}
// The cut is an anti-aliased pixel or two, not a blur.
for y in 0..32 {
let (lo, hi) = (out[y][16], out[y][48]);
let soft = out[y].iter().filter(|&&v| (v - lo).abs() > 0.01 && (v - hi).abs() > 0.01).count();
assert!(soft <= 2, "row {y}: {soft} edge pixels");
}
// A ramp becomes `numberOfLevels` flat steps (the palette is the image's own colours, so
// one may go to the wide flat margins and merge as noise).
let ramp: Vec<Vec<f32>> = (0..12).map(|_| (0..144).map(|x| ((x as f32 - 24.0) / 95.0).clamp(0.0, 1.0)).collect()).collect();
for levels in [2.0, 5.0, 8.0] {
let row = &cutout_on(&ramp, &[("numberOfLevels", levels)])[6];
let mut tones: Vec<f32> = Vec::new();
for win in row.windows(3) {
if (win[0] - win[2]).abs() < 1e-4 && tones.last().is_none_or(|t| (t - win[1]).abs() > 1e-3) {
tones.push(win[1]);
}
}
assert!(tones.len() <= levels as usize && tones.len() + 1 >= levels as usize, "levels {levels}: tones {tones:?}");
assert!(tones.windows(2).all(|t| t[0] < t[1]), "levels {levels}: tones {tones:?}");
}
// Edge Simplicity drops specks.
let speck: Vec<Vec<f32>> = (0..32).map(|y| (0..32).map(|x| if (15..18).contains(&x) && (15..18).contains(&y) { 0.9 } else { 0.2 }).collect()).collect();
let detailed = cutout_on(&speck, &[("edgeSimplicity", 0.0)]);
let simple = cutout_on(&speck, &[("edgeSimplicity", 10.0)]);
assert!(detailed[16][16] - detailed[2][2] > 0.5, "the speck should survive at simplicity 0");
assert!((simple[16][16] - simple[2][2]).abs() < 1e-4, "the speck should go at simplicity 10");
}
#[test]
fn cutout_keeps_the_tone_of_surviving_islands_at_low_fidelity() {
for (size, simplicity) in [(2, 0.0), (3, 2.0), (4, 0.0), (4, 2.0), (6, 2.0)] {
let rows: Vec<Vec<f32>> =
(0..32).map(|y| (0..32).map(|x| if (12..12 + size).contains(&x) && (12..12 + size).contains(&y) { 0.9 } else { 0.2 }).collect()).collect();
let reference = cutout_on(&rows, &[("edgeSimplicity", simplicity), ("edgeFidelity", 2.0)]);
let centre = 12 + size / 2;
for fidelity in [1.0, 1.5, 2.0] {
let out = cutout_on(&rows, &[("edgeSimplicity", simplicity), ("edgeFidelity", fidelity)]);
assert!(
size == 2 || (out[centre][centre] - reference[centre][centre]).abs() < 0.02,
"{size}x{size}, simplicity {simplicity}, fidelity {fidelity}: {} vs {}",
out[centre][centre],
reference[centre][centre]
);
assert!(out[centre][centre] - out[2][2] > 0.5, "the island keeps its contrast");
}
}
}
#[test]
fn a_stack_around_a_cutout_is_tile_independent() {
// Effects before a Cutout are traced with it over the whole image; effects after it run
// per tile on the filled-in shapes.
use crate::GalleryFilter as G;
let b = Rect::new(0, 0, 56, 40);
let s = pattern(SampleType::F32);
let fp =
FilterParams::FilterGallery { effects: vec![GalleryEffect::new(G::Spatter), GalleryEffect::new(G::Cutout), GalleryEffect::new(G::GlowingEdges)] };
let a = apply_tiled(&s, &fp, b, b, None, 256, Some(b)).read_region(b);
let t = apply_tiled(&s, &fp, b, b, None, 16, Some(b)).read_region(b);
let worst = a.iter().zip(&t).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
assert!(worst < 1e-3, "differs across tilings by {worst}");
}
/// Flat greys keep their tone through every pattern and Contrast, black and white stay
/// solid, and Contrast makes the screen crisper.
#[test]
+6 -7
View File
@@ -144,13 +144,12 @@ fn conv_row<const N: usize>(src: &[f32], dst: &mut [f32], kernel: &[f32]) {
pub(crate) fn gaussian(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec<f32> {
// Exact kernel for small radii; beyond that three box passes approximate the Gaussian with
// cost independent of the radius (Kovesi, "Fast almost-Gaussian filtering", 2010).
match box_widths(&FilterParams::GaussianBlur { radius }) {
Some(boxes) => box_passes(src, out, ctx.alpha, &boxes),
None => {
let k = gaussian_blur_kernel(radius);
conv_sep(src, out, &k, &k, ctx.alpha)
}
// cost independent of the radius (Kovesi, "Fast almost-Gaussian filtering", 2010). Built
// from the description other devices run (`separable`), so they blur the same (#5772).
match crate::separable::Separable::of(&FilterParams::GaussianBlur { radius }) {
Some(crate::separable::Separable::Boxes(boxes)) => box_passes(src, out, ctx.alpha, &boxes),
Some(crate::separable::Separable::Kernel(k)) => conv_sep(src, out, &k, &k, ctx.alpha),
None => src.crop(out),
}
}
+263
View File
@@ -0,0 +1,263 @@
//! Colour and palette: Oklab, edge-keeping smoothing, and the picture's own palette.
use super::Px;
use super::util::transpose;
use crate::par;
use crate::quantize::{kmeans, median_cut};
/// Oklab (Björn Ottosson, 2020) of an sRGB colour.
pub(super) fn oklab(p: Px) -> Px {
let lin = |v: f32| {
let v = v.clamp(0.0, 1.0);
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
};
let (r, g, b) = (lin(p[0]), lin(p[1]), lin(p[2]));
let l = (0.412_221_46 * r + 0.536_332_55 * g + 0.051_445_995 * b).cbrt();
let m = (0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b).cbrt();
let s = (0.088_302_46 * r + 0.281_718_85 * g + 0.629_978_7 * b).cbrt();
[
0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s,
1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s,
0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s,
]
}
/// sRGB of an Oklab colour, or `None` outside the sRGB gamut.
pub(super) fn srgb(lab: Px) -> Option<Px> {
let l = (lab[0] + 0.396_337_78 * lab[1] + 0.215_803_76 * lab[2]).powi(3);
let m = (lab[0] - 0.105_561_346 * lab[1] - 0.063_854_17 * lab[2]).powi(3);
let s = (lab[0] - 0.089_484_18 * lab[1] - 1.291_485_5 * lab[2]).powi(3);
let lin = [
4.076_741_7 * l - 3.307_711_6 * m + 0.230_969_94 * s,
-1.268_438 * l + 2.609_757_4 * m - 0.341_319_38 * s,
-0.004_196_086_3 * l - 0.703_418_6 * m + 1.707_614_7 * s,
];
if lin.iter().any(|v| !(-1e-4..=1.0001).contains(v)) {
return None;
}
let enc = |v: f32| {
let v = v.clamp(0.0, 1.0);
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
};
Some(lin.map(enc))
}
/// sRGB at Oklab lightness `l`, hue/strength `a`, `b`; out of gamut it loses strength, not hue.
pub(super) fn in_gamut(l: f32, a: f32, b: f32) -> Px {
if let Some(c) = srgb([l, a, b]) {
return c;
}
let (mut ok, mut bad) = (0.0f32, 1.0f32);
for _ in 0..10 {
let t = (ok + bad) / 2.0;
if srgb([l, a * t, b * t]).is_some() { ok = t } else { bad = t }
}
srgb([l, a * ok, b * ok]).unwrap_or([l, l, l])
}
/// Squared distance between two colours.
pub(super) fn dist2(a: Px, b: Px) -> f32 {
(a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)
}
/// Bilateral smoothing in Oklab, `rounds` times along rows then columns.
pub(super) fn smooth(mut lab: Vec<Px>, w: usize, h: usize, rounds: usize) -> Vec<Px> {
for _ in 0..rounds {
bilateral_rows(&mut lab, w);
let mut t = transpose(&lab, w, h);
bilateral_rows(&mut t, h);
lab = transpose(&t, h, w);
}
lab
}
/// One bilateral pass along every row of `buf` (rows of `w` pixels), seven pixels wide.
fn bilateral_rows(buf: &mut [Px], w: usize) {
const R: isize = 3;
// Colours this far apart (Oklab) count half as much as equal ones (a Cauchy weight: as
// edge-keeping as a Gaussian one and much cheaper).
const HALF: f32 = 0.06;
let k = 1.0 / (HALF * HALF);
let spatial: [f32; 7] = std::array::from_fn(|i| (-((i as f32 - 3.0).powi(2)) / 8.0).exp());
par::chunks_mut(buf, w, true, |_, row| {
let src = row.to_vec();
for (x, out) in row.iter_mut().enumerate() {
let c = src[x];
let (mut acc, mut wsum) = ([0.0f32; 3], 0.0f32);
for d in -R..=R {
let q = src[(x as isize + d).clamp(0, w as isize - 1) as usize];
let wt = spatial[(d + R) as usize] / (1.0 + dist2(q, c) * k);
acc = [acc[0] + q[0] * wt, acc[1] + q[1] * wt, acc[2] + q[2] * wt];
wsum += wt;
}
*out = [acc[0] / wsum, acc[1] / wsum, acc[2] / wsum];
}
});
}
/// At most `levels` colours of the image's own; colours closer than noise are merged.
pub(super) fn palette(lab: &[Px], levels: usize) -> Vec<Px> {
const SAMPLES: usize = 1 << 16;
const SAME: f32 = 0.045;
// Hue centres (Oklab a, b) this far apart are different colours, not one colour's spread.
const HUES_APART: f32 = 0.08;
let step = lab.len().div_ceil(SAMPLES).max(1);
let pts: Vec<(Px, u32)> = lab.iter().step_by(step).map(|p| (*p, 1)).collect();
let mut centres = median_cut(&pts, levels);
kmeans(&pts, &mut centres, 10);
// Number of Levels counts tones: a palette colour whose pixels are two clearly different hues
// of that tone (a blue and a pink) splits in two, or touching areas of both would be one
// shape of their grey average.
let mut split = Vec::with_capacity(centres.len() * 2);
for (k, c) in centres.iter().enumerate() {
let members: Vec<(Px, u32)> = pts.iter().filter(|(p, _)| nearest(&centres, *p) as usize == k).map(|(p, _)| ([0.0, p[1], p[2]], 1)).collect();
let mut hues = median_cut(&members, 2);
kmeans(&members, &mut hues, 6);
match hues.as_slice() {
[a, b] if dist2(*a, *b).sqrt() > HUES_APART => split.extend([[c[0], a[1], a[2]], [c[0], b[1], b[2]]]),
_ => split.push(*c),
}
}
let mut centres = split;
kmeans(&pts, &mut centres, 4);
let mut weight = vec![0u32; centres.len()];
for (p, _) in &pts {
if let Some(wt) = weight.get_mut(nearest(&centres, *p) as usize) {
*wt += 1;
}
}
let mut cs: Vec<(Px, f32)> = centres.into_iter().zip(weight).filter(|c| c.1 > 0).map(|(c, wt)| (c, wt as f32)).collect();
loop {
let mut best: Option<(usize, usize, f32)> = None;
for i in 0..cs.len() {
for j in i + 1..cs.len() {
let d = dist2(cs[i].0, cs[j].0).sqrt();
if d < SAME && best.is_none_or(|b| d < b.2) {
best = Some((i, j, d));
}
}
}
let Some((i, j, _)) = best else { break };
let (a, b) = (cs[i], cs[j]);
let t = a.1 + b.1;
cs[i] = (std::array::from_fn(|k| (a.0[k] * a.1 + b.0[k] * b.1) / t), t);
cs.remove(j);
}
if cs.is_empty() {
return vec![[0.5, 0.0, 0.0]];
}
cs.into_iter().map(|c| c.0).collect()
}
/// Index of the palette colour closest to `p`.
pub(super) fn nearest(palette: &[Px], p: Px) -> u8 {
let mut best = (0usize, f32::MAX);
for (i, c) in palette.iter().enumerate() {
let d = dist2(*c, p);
if d < best.1 {
best = (i, d);
}
}
best.0 as u8
}
#[cfg(test)]
mod tests {
use super::*;
use crate::photo_util::hash01;
/// Oklab pixels of `w`×`h`: `colour(x)` plus noise of ±`noise` in each channel.
fn noisy(w: usize, h: usize, noise: f32, colour: impl Fn(usize) -> Px) -> Vec<Px> {
(0..w * h)
.map(|i| {
let (x, y) = ((i % w) as i64, (i / w) as i64);
let c = colour(i % w);
std::array::from_fn(|k| c[k] + (hash01(x, y, k as u64 + 1) - 0.5) * 2.0 * noise)
})
.collect()
}
#[test]
fn oklab_round_trips_and_marks_the_gamut() {
let white = oklab([1.0, 1.0, 1.0]);
assert!((white[0] - 1.0).abs() < 1e-3 && white[1].abs() < 1e-3 && white[2].abs() < 1e-3, "{white:?}");
assert!(oklab([0.0, 0.0, 0.0])[0].abs() < 1e-6);
for p in [[0.2, 0.5, 0.9], [0.95, 0.1, 0.3], [0.5, 0.5, 0.5], [0.0, 1.0, 0.0]] {
let back = srgb(oklab(p)).unwrap_or([-1.0; 3]);
assert!(dist2(back, p) < 1e-6, "{p:?} came back as {back:?}");
}
// Very light and very colourful: no sRGB colour is that.
assert_eq!(srgb([0.95, 0.3, 0.0]), None);
}
#[test]
fn out_of_gamut_colours_lose_strength_not_hue() {
let (l, a, b) = (0.95, 0.3, 0.1);
let c = in_gamut(l, a, b);
let lab = oklab(c);
assert!((lab[0] - l).abs() < 0.01, "lightness kept: {lab:?}");
let chroma = (lab[1] * lab[1] + lab[2] * lab[2]).sqrt();
assert!(chroma > 0.01 && chroma < (a * a + b * b).sqrt(), "weaker but still coloured: {chroma}");
assert!((lab[2] / lab[1] - b / a).abs() < 0.05, "same hue: {lab:?}");
// In gamut it is just the conversion.
assert_eq!(in_gamut(0.6, 0.02, -0.03), srgb([0.6, 0.02, -0.03]).unwrap_or_default());
}
#[test]
fn smoothing_flattens_noise_and_keeps_edges() {
let (w, h) = (40usize, 20usize);
let lab = noisy(w, h, 0.02, |x| if x < 20 { [0.3, 0.0, 0.0] } else { [0.8, 0.0, 0.0] });
let out = smooth(lab.clone(), w, h, 2);
let spread = |v: &[Px], xs: std::ops::Range<usize>| {
let l: Vec<f32> = (0..h).flat_map(|y| xs.clone().map(move |x| y * w + x)).map(|i| v[i][0]).collect();
let mean = l.iter().sum::<f32>() / l.len() as f32;
(l.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / l.len() as f32).sqrt()
};
assert!(spread(&out, 2..16) < spread(&lab, 2..16) / 2.0, "noise kept: {} vs {}", spread(&out, 2..16), spread(&lab, 2..16));
// The step stays a step: the pixels either side of it keep most of their difference.
let step = (0..h).map(|y| out[y * w + 20][0] - out[y * w + 19][0]).sum::<f32>() / h as f32;
assert!(step > 0.4, "edge blurred to {step}");
}
#[test]
fn the_palette_finds_the_pictures_own_colours() {
let (w, h) = (60usize, 20usize);
let (dark, light) = ([0.25, 0.02, -0.03], [0.75, -0.04, 0.05]);
let lab = noisy(w, h, 0.01, |x| if x < 30 { dark } else { light });
// Asked for up to six, it gives two: colours only noise apart are one.
let p = palette(&lab, 6);
assert_eq!(p.len(), 2, "{p:?}");
for c in [dark, light] {
assert!(p.iter().any(|q| dist2(*q, c).sqrt() < 0.02), "{c:?} not in {p:?}");
}
assert_eq!(nearest(&p, dark), nearest(&p, [0.27, 0.0, -0.02]));
assert_ne!(nearest(&p, dark), nearest(&p, light));
}
#[test]
fn a_tone_of_two_different_hues_splits_in_two() {
// A dark tone and a light one, the light one half blue and half pink: two levels give
// three colours, or the blue and pink areas would be one shape of their grey average.
let (w, h) = (60usize, 20usize);
let (dark, blue, pink) = ([0.2, 0.0, 0.0], [0.7, -0.03, -0.1], [0.7, 0.12, 0.0]);
let lab = noisy(w, h, 0.005, |x| {
if x < 30 {
dark
} else if x < 45 {
blue
} else {
pink
}
});
let p = palette(&lab, 2);
assert_eq!(p.len(), 3, "{p:?}");
for c in [dark, blue, pink] {
assert!(p.iter().any(|q| dist2(*q, c).sqrt() < 0.03), "{c:?} not in {p:?}");
}
}
#[test]
fn an_empty_image_still_has_a_palette() {
assert_eq!(palette(&[], 4).len(), 1);
}
}
+380
View File
@@ -0,0 +1,380 @@
//! Curves: each chain fitted once, with its corners kept, its runs simplified (Douglas–Peucker)
//! and drawn as Catmull-Rom curves.
use super::{Pt, Settings};
/// `a - b`.
fn sub(a: Pt, b: Pt) -> Pt {
[a[0] - b[0], a[1] - b[1]]
}
/// Length of `a`.
fn len(a: Pt) -> f32 {
(a[0] * a[0] + a[1] * a[1]).sqrt()
}
/// Fits one chain: corners kept, runs simplified and smoothed. Its ends stay where they are, so
/// chains meeting at a junction still meet.
pub(super) fn fit(pts: &[Pt], closed: bool, s: &Settings) -> Vec<Pt> {
let n = pts.len();
if n < 3 {
return pts.to_vec();
}
let original = pts;
let rounded = round_off(pts, closed, s.rounding());
let pts = &rounded[..];
let at = |i: isize| pts[i.rem_euclid(n as isize) as usize];
// Turn at each point, measured over a few cracks either side.
const K: isize = 3;
let turn = |i: isize| {
let (a, b) = (sub(at(i), at(i - K)), sub(at(i + K), at(i)));
let (la, lb) = (len(a), len(b));
if la < 1e-6 || lb < 1e-6 { 0.0 } else { ((a[0] * b[0] + a[1] * b[1]) / (la * lb)).clamp(-1.0, 1.0).acos() }
};
let span = if closed { 0..n as isize } else { K..(n as isize - K) };
let turns: Vec<(isize, f32)> = span.clone().map(|i| (i, turn(i))).collect();
let limit = s.corner_turn();
let mut corners: Vec<usize> = Vec::new();
for &(i, t) in &turns {
if t <= limit {
continue;
}
// Only the sharpest point of a bend.
let sharpest = (-K..=K).all(|o| {
let j = i + o;
let in_span = closed || span.contains(&j);
!in_span || o == 0 || {
let tj = turn(j);
tj < t || (tj == t && o > 0)
}
});
if sharpest {
corners.push(i.rem_euclid(n as isize) as usize);
}
}
// Square a corner back where the pixels cut it: where the straight runs either side meet.
let mut p = pts.to_vec();
for &i in &corners {
let ii = i as isize;
if let Some(q) = intersect(at(ii - K), at(ii - 1), at(ii + 1), at(ii + K))
&& len(sub(q, at(ii))) <= 1.0
{
p[i] = q;
}
}
let mut keep: Vec<usize> = if closed { corners.clone() } else { std::iter::once(0).chain(corners.iter().copied()).chain(std::iter::once(n - 1)).collect() };
keep.sort_unstable();
keep.dedup();
let mut is_corner = vec![false; n];
for &k in &keep {
is_corner[k] = true;
}
if closed && keep.len() < 2 {
// A smooth loop: split it at two far-apart points (not corners) to simplify each half.
let a = keep.first().copied().unwrap_or(0);
let far = (0..n).max_by(|&i, &j| len(sub(p[i], p[a])).total_cmp(&len(sub(p[j], p[a])))).unwrap_or(n / 2);
keep = if a == far { vec![a, (a + n / 2) % n] } else { vec![a.min(far), a.max(far)] };
}
// Douglas–Peucker between consecutive kept points.
let tol = s.tolerance();
let mut verts: Vec<usize> = Vec::new();
let pieces = if closed { keep.len() } else { keep.len() - 1 };
for k in 0..pieces {
let a = keep[k];
let b = if k + 1 < keep.len() { keep[k + 1] } else { keep[0] + n };
verts.push(a);
let idx: Vec<usize> = (a..=b).collect();
let mut marks = vec![false; idx.len()];
simplify(&idx.iter().map(|&i| p[i % n]).collect::<Vec<_>>(), 0, idx.len() - 1, tol, &mut marks);
verts.extend(idx.iter().zip(&marks).skip(1).take(idx.len().saturating_sub(2)).filter(|m| *m.1).map(|(&i, _)| i % n));
}
if !closed {
verts.push(n - 1);
}
let v: Vec<Pt> = verts.iter().map(|&i| p[i]).collect();
let corner: Vec<bool> = verts.iter().map(|&i| is_corner[i]).collect();
let out = curves(&v, &corner, closed);
if closed {
let before = signed_area(original);
let after = signed_area(&out);
// Region merging decides which islands survive. Rounding and simplification must not
// erase one (or reverse its hole): keep the original shared outline if half its area
// would be lost. Close it explicitly because the fill consumes consecutive segments.
if before * after <= 0.0 || after.abs() < before.abs() * 0.5 {
let mut out = original.to_vec();
out.extend(original.first().copied());
return out;
}
}
out
}
/// Signed loop area, relative to its first point to avoid cancellation far from the origin.
fn signed_area(pts: &[Pt]) -> f64 {
let Some(origin) = pts.first() else { return 0.0 };
pts.iter()
.zip(pts.iter().cycle().skip(1))
.map(|(a, b)| {
let (ax, ay) = (f64::from(a[0]) - f64::from(origin[0]), f64::from(a[1]) - f64::from(origin[1]));
let (bx, by) = (f64::from(b[0]) - f64::from(origin[0]), f64::from(b[1]) - f64::from(origin[1]));
ax * by - bx * ay
})
.sum::<f64>()
* 0.5
}
/// Two passes of a moving average over `r` points either side; an open chain's ends (junctions)
/// stay put, and the window shrinks towards them so they don't pull the outline.
fn round_off(pts: &[Pt], closed: bool, r: usize) -> Vec<Pt> {
let n = pts.len();
let mut p = pts.to_vec();
if r == 0 || n < 3 {
return p;
}
for _ in 0..2 {
let src = p.clone();
for (i, q) in p.iter_mut().enumerate() {
let rr = if closed { r.min((n - 1) / 2) } else { r.min(i).min(n - 1 - i) };
if rr == 0 {
continue;
}
let (mut sx, mut sy) = (0.0f32, 0.0f32);
for o in -(rr as isize)..=rr as isize {
let s = src[(i as isize + o).rem_euclid(n as isize) as usize];
sx += s[0];
sy += s[1];
}
let k = (2 * rr + 1) as f32;
*q = [sx / k, sy / k];
}
}
p
}
/// Where the lines through `a0`–`a1` and `b0`–`b1` cross, if they aren't parallel.
fn intersect(a0: Pt, a1: Pt, b0: Pt, b1: Pt) -> Option<Pt> {
let (da, db) = (sub(a1, a0), sub(b1, b0));
let den = da[0] * db[1] - da[1] * db[0];
if den.abs() < 1e-6 {
return None;
}
let t = ((b0[0] - a0[0]) * db[1] - (b0[1] - a0[1]) * db[0]) / den;
Some([a0[0] + da[0] * t, a0[1] + da[1] * t])
}
/// Marks the points of `p[a..=b]` that Douglas–Peucker keeps at tolerance `tol`.
fn simplify(p: &[Pt], a: usize, b: usize, tol: f32, marks: &mut [bool]) {
let mut stack = vec![(a, b)];
while let Some((a, b)) = stack.pop() {
if b <= a + 1 {
continue;
}
let (pa, d) = (p[a], sub(p[b], p[a]));
let l = len(d);
let mut best = (a, 0.0f32);
for (i, q) in p.iter().enumerate().take(b).skip(a + 1) {
let e = sub(*q, pa);
let dist = if l < 1e-6 { len(e) } else { (e[0] * d[1] - e[1] * d[0]).abs() / l };
if dist > best.1 {
best = (i, dist);
}
}
if best.1 > tol {
marks[best.0] = true;
stack.push((a, best.0));
stack.push((best.0, b));
}
}
}
/// Catmull-Rom curves through `v` (one-sided at corners), flattened to a polyline.
fn curves(v: &[Pt], corner: &[bool], closed: bool) -> Vec<Pt> {
let m = v.len();
if m < 2 {
return v.to_vec();
}
let at = |i: isize| v[i.rem_euclid(m as isize) as usize];
let sharp = |i: isize| (!closed && (i <= 0 || i >= m as isize - 1)) || corner[i.rem_euclid(m as isize) as usize];
let mut out = vec![v[0]];
let segs = if closed { m } else { m - 1 };
for i in 0..segs as isize {
let (p0, p1) = (at(i), at(i + 1));
let d = len(sub(p1, p0));
let (s0, s1) = (sharp(i), sharp(i + 1));
if s0 && s1 || d < 1e-6 {
out.push(p1);
continue;
}
// Tangents scaled to this segment, so short and long neighbours don't overshoot.
let t0 = if s0 {
sub(p1, p0)
} else {
let dp = len(sub(p0, at(i - 1)));
let k = d / (dp + d).max(1e-6);
let t = sub(p1, at(i - 1));
[t[0] * k, t[1] * k]
};
let t1 = if s1 {
sub(p1, p0)
} else {
let dn = len(sub(at(i + 2), p1));
let k = d / (d + dn).max(1e-6);
let t = sub(at(i + 2), p0);
[t[0] * k, t[1] * k]
};
let c1 = [p0[0] + t0[0] / 3.0, p0[1] + t0[1] / 3.0];
let c2 = [p1[0] - t1[0] / 3.0, p1[1] - t1[1] / 3.0];
let steps = ((d / 1.5).ceil() as usize).clamp(2, 24);
for k in 1..=steps {
let t = k as f32 / steps as f32;
let u = 1.0 - t;
let (b0, b1, b2, b3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
out.push([b0 * p0[0] + b1 * c1[0] + b2 * c2[0] + b3 * p1[0], b0 * p0[1] + b1 * c1[1] + b2 * c2[1] + b3 * p1[1]]);
}
}
out
}
/// Bounding box `[x0, y0, x1, y1]` of `pts`.
pub(super) fn bounds(pts: &[Pt]) -> [f32; 4] {
pts.iter().fold([f32::MAX, f32::MAX, f32::MIN, f32::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])])
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(fidelity: f32) -> Settings {
Settings { levels: 4, simplicity: 2.0, fidelity }
}
/// The outline of a square from (2, 2) to (12, 12), one point per unit, starting at a corner.
fn square() -> Vec<Pt> {
let mut p = Vec::new();
for i in 0..10 {
p.push([2.0 + i as f32, 2.0]);
}
for i in 0..10 {
p.push([12.0, 2.0 + i as f32]);
}
for i in 0..10 {
p.push([12.0 - i as f32, 12.0]);
}
for i in 0..10 {
p.push([2.0, 12.0 - i as f32]);
}
p
}
fn close(a: Pt, b: Pt) -> bool {
len(sub(a, b)) < 1e-3
}
#[test]
fn douglas_peucker_keeps_only_points_off_the_line() {
// An L, slightly ragged: only its corner is further from the end-to-end line than the
// tolerance, and the two arms are straight within it.
let wobble = |i: usize| 0.05 * (i % 2) as f32;
let p: Vec<Pt> = (0..9).map(|i| if i <= 4 { [i as f32, wobble(i)] } else { [4.0 + wobble(i), (i - 4) as f32] }).collect();
let mut marks = vec![false; p.len()];
simplify(&p, 0, 8, 0.5, &mut marks);
assert_eq!(marks.iter().filter(|m| **m).count(), 1, "{marks:?}");
assert!(marks[4]);
let mut marks = vec![false; p.len()];
simplify(&p, 0, 8, 3.0, &mut marks);
assert!(marks.iter().all(|m| !m), "under the tolerance nothing is kept");
}
#[test]
fn lines_cross_where_they_should() {
let q = intersect([0.0, 0.0], [1.0, 1.0], [0.0, 2.0], [1.0, 1.0]);
assert!(q.is_some_and(|q| close(q, [1.0, 1.0])), "{q:?}");
assert_eq!(intersect([0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [2.0, 1.0]), None);
}
#[test]
fn rounding_off_keeps_an_open_chains_ends() {
let p: Vec<Pt> = (0..12).map(|i| [i as f32, if i % 2 == 0 { 0.0 } else { 1.0 }]).collect();
let r = round_off(&p, false, 3);
assert_eq!((r[0], r[11]), (p[0], p[11]));
assert!(r[5][1] > 0.2 && r[5][1] < 0.8, "the zigzag is averaged out: {:?}", r[5]);
assert_eq!(round_off(&p, false, 0), p);
}
#[test]
fn a_square_keeps_its_four_corners_and_straight_sides() {
let out = fit(&square(), true, &settings(2.0));
for c in [[2.0, 2.0], [12.0, 2.0], [12.0, 12.0], [2.0, 12.0]] {
assert!(out.iter().any(|p| close(*p, c)), "corner {c:?} lost: {out:?}");
}
let on_side = |p: &Pt| [p[0] - 2.0, p[0] - 12.0, p[1] - 2.0, p[1] - 12.0].iter().any(|d| d.abs() < 1e-3);
assert!(out.iter().all(on_side), "{out:?}");
assert!(out.len() < 10, "the sides are simplified to their ends: {out:?}");
}
#[test]
fn a_rounded_corner_is_squared_back() {
// The pixels cut the corner at (12, 2): the outline goes (11, 2), (12, 3) instead.
let mut p = square();
p[10] = [11.5, 2.5];
let out = fit(&p, true, &settings(2.0));
assert!(out.iter().any(|q| close(*q, [12.0, 2.0])), "{out:?}");
}
#[test]
fn an_open_chain_ends_where_it_started_at_any_fidelity() {
let p: Vec<Pt> = (0..30).map(|i| [i as f32, (i as f32 / 5.0).sin() * 4.0 + 0.3 * (i % 3) as f32]).collect();
for f in [1.0, 2.0, 3.0] {
let out = fit(&p, false, &settings(f));
assert_eq!((out.first(), out.last()), (p.first(), p.last()), "fidelity {f}");
}
}
#[test]
fn curves_pass_through_their_vertices() {
let v = [[0.0, 0.0], [4.0, 3.0], [8.0, 0.0], [12.0, 4.0]];
let out = curves(&v, &[false; 4], false);
for p in v {
assert!(out.iter().any(|q| close(*q, p)), "{p:?} missed: {out:?}");
}
assert!(out.len() > 8, "flattened into steps: {}", out.len());
// Between two corners a segment is straight.
assert_eq!(curves(&v, &[true; 4], false), v.to_vec());
}
#[test]
fn fitting_keeps_small_islands_and_their_winding() {
use super::super::edges::follow_edges;
let (w, h) = (32, 32);
for size in [2, 3, 4, 6] {
let reg: Vec<u32> = (0..w * h).map(|i| u32::from((12..12 + size).contains(&(i % w)) && (12..12 + size).contains(&(i / w)))).collect();
let colours = [[0.2; 3], [0.9; 3]];
let c: Vec<_> = reg.iter().map(|&r| colours[r as usize]).collect();
let (chains, _) = follow_edges(&reg, &colours, &c, w, h);
let (pts, closed) = chains.iter().find(|(_, closed)| *closed).unwrap();
assert!(*closed);
let area = |p: &[Pt]| p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| a[0] as f64 * b[1] as f64 - b[0] as f64 * a[1] as f64).sum::<f64>() / 2.0;
let original = area(pts);
for simplicity in [0.0, 2.0] {
let s = Settings { simplicity, ..settings(2.0) };
if size * size < s.min_area() as usize {
continue;
}
for fidelity in [1.0, 1.5, 2.0, 3.0] {
let out = fit(pts, true, &Settings { fidelity, ..s });
let fitted = area(&out);
assert!(
fitted * original > 0.0 && fitted.abs() >= original.abs() * 0.5,
"{size}x{size}, simplicity {simplicity}, fidelity {fidelity}: {original} -> {fitted}"
);
assert_eq!(out.first(), out.last(), "fallbacks must close the loop for the fill");
}
}
}
}
#[test]
fn bounds_cover_every_point() {
assert_eq!(bounds(&[[1.0, 5.0], [-2.0, 3.0], [4.0, 4.0]]), [-2.0, 3.0, 4.0, 5.0]);
}
}
+210
View File
@@ -0,0 +1,210 @@
//! Edges: the cracks between regions, linked into chains from junction to junction, each crack
//! placed where the anti-aliasing says the edge is.
use super::{OUT, Pt, Px};
use crate::par;
/// A chain's points, and whether it is closed.
pub(super) type Chain = (Vec<Pt>, bool);
/// A pixel, or a lattice point (the corner at a pixel's top left).
type Cell = (i64, i64);
/// A step along the lattice, y down.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Dir {
E,
W,
S,
N,
}
/// Links the cracks between regions into chains. Each chain is its points (the junctions it runs
/// between, if any, and one sub-pixel point per crack) and whether it is closed; its sides are
/// the regions on its left and right (y down).
pub(super) fn follow_edges(reg: &[u32], colours: &[Px], c: &[Px], w: usize, h: usize) -> (Vec<Chain>, Vec<(u32, u32)>) {
let (wi, hi) = (w as i64, h as i64);
let at = |x: i64, y: i64| -> u32 { if x < 0 || y < 0 || x >= wi || y >= hi { OUT } else { reg[y as usize * w + x as usize] } };
let hcount = w * (h + 1);
// Horizontal crack (x, y)–(x+1, y) is `y * w + x`; vertical (x, y)–(x, y+1) follows them.
let hc = |x: i64, y: i64| (y as usize) * w + x as usize;
let vc = |x: i64, y: i64| hcount + (y as usize) * (w + 1) + x as usize;
let mut visited = vec![false; hcount + (w + 1) * h];
// The crack leaving lattice point (x, y) in `d`: its id, the pixels on its left and right,
// and the point it reaches.
let step = |x: i64, y: i64, d: Dir| -> Option<(usize, Cell, Cell, Cell)> {
match d {
Dir::E if x < wi => Some((hc(x, y), (x, y - 1), (x, y), (x + 1, y))),
Dir::W if x > 0 => Some((hc(x - 1, y), (x - 1, y), (x - 1, y - 1), (x - 1, y))),
Dir::S if y < hi => Some((vc(x, y), (x, y), (x - 1, y), (x, y + 1))),
Dir::N if y > 0 => Some((vc(x, y - 1), (x - 1, y - 1), (x, y - 1), (x, y - 1))),
_ => None,
}
};
// Which cracks separate two regions, and which lattice points are junctions, worked out
// once (in parallel) before the walk.
let mut is_edge = vec![false; visited.len()];
let (hb, vb) = is_edge.split_at_mut(hcount);
par::chunks_mut(hb, w, true, |y, row| {
for (x, b) in row.iter_mut().enumerate() {
*b = at(x as i64, y as i64 - 1) != at(x as i64, y as i64);
}
});
par::chunks_mut(vb, w + 1, true, |y, row| {
for (x, b) in row.iter_mut().enumerate() {
*b = at(x as i64 - 1, y as i64) != at(x as i64, y as i64);
}
});
let boundary = |x: i64, y: i64, d: Dir| step(x, y, d).filter(|s| is_edge[s.0]);
let mut is_junction = vec![false; (w + 1) * (h + 1)];
par::chunks_mut(&mut is_junction, w + 1, true, |y, row| {
let y = y as i64;
for (x, j) in row.iter_mut().enumerate() {
let x = x as i64;
let deg = [Dir::E, Dir::W, Dir::S, Dir::N].iter().filter(|&&d| boundary(x, y, d).is_some()).count();
*j = deg >= 3 || (deg > 0 && (x == 0 || x == wi) && (y == 0 || y == hi));
}
});
let junction = |x: i64, y: i64| is_junction[y as usize * (w + 1) + x as usize];
// One point per crack: its middle, moved along the normal from left to right pixel by what
// their colours say about where the edge really is.
let crack_point = |l: (i64, i64), r: (i64, i64)| -> Pt {
let mid = [(l.0 + r.0 + 1) as f32 / 2.0, (l.1 + r.1 + 1) as f32 / 2.0];
let (n0, n1) = ((r.0 - l.0) as f32, (r.1 - l.1) as f32);
let (ra, rb) = (at(l.0, l.1), at(r.0, r.1));
let (Some(ca), Some(cb)) = (colours.get(ra as usize), colours.get(rb as usize)) else { return mid };
let d = [cb[0] - ca[0], cb[1] - ca[1], cb[2] - ca[2]];
let dd = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if dd < 1e-4 {
return mid;
}
// How far each pixel's colour is from its own region's towards the other's (0–1).
let frac = |p: (i64, i64)| {
let q = c[p.1 as usize * w + p.0 as usize];
((q[0] - ca[0]) * d[0] + (q[1] - ca[1]) * d[1] + (q[2] - ca[2]) * d[2]) / dd
};
let (fl, fr) = (frac(l).clamp(0.0, 1.0), frac(r).clamp(0.0, 1.0));
let off = ((1.0 - fr) - fl).clamp(-0.5, 0.5);
[mid[0] + n0 * off, mid[1] + n1 * off]
};
let mut chains = Vec::new();
let mut sides = Vec::new();
let mut walk = |x: i64, y: i64, first: Dir, visited: &mut Vec<bool>| {
let open = junction(x, y);
let mut pts = if open { vec![[x as f32, y as f32]] } else { Vec::new() };
let (mut cx, mut cy, mut d) = (x, y, first);
let mut side = (OUT, OUT);
let mut closed = !open;
while let Some((id, l, r, to)) = boundary(cx, cy, d) {
if visited[id] {
break;
}
visited[id] = true;
if pts.len() <= usize::from(open) {
side = (at(l.0, l.1), at(r.0, r.1));
}
pts.push(crack_point(l, r));
(cx, cy) = to;
if junction(cx, cy) {
pts.push([cx as f32, cy as f32]);
closed = false;
break;
}
let back = match d {
Dir::E => Dir::W,
Dir::W => Dir::E,
Dir::S => Dir::N,
Dir::N => Dir::S,
};
let Some(next) = [Dir::E, Dir::S, Dir::W, Dir::N].into_iter().find(|&nd| nd != back && boundary(cx, cy, nd).is_some_and(|b| !visited[b.0])) else {
break;
};
d = next;
}
if pts.len() >= 2 {
chains.push((pts, closed));
sides.push(side);
}
};
for y in 0..=hi {
for x in 0..=wi {
if junction(x, y) {
for d in [Dir::E, Dir::S, Dir::W, Dir::N] {
if boundary(x, y, d).is_some_and(|b| !visited[b.0]) {
walk(x, y, d, &mut visited);
}
}
}
}
}
// What is left are closed loops with no junction (an island inside one other region).
for y in 0..=hi {
for x in 0..=wi {
for d in [Dir::E, Dir::S] {
if boundary(x, y, d).is_some_and(|b| !visited[b.0]) {
walk(x, y, d, &mut visited);
}
}
}
}
(chains, sides)
}
#[cfg(test)]
mod tests {
use super::*;
/// Regions drawn as digits, one per pixel, with grey `colours` (region `k` is `k / 4`).
fn regions(rows: &[&str]) -> (Vec<u32>, Vec<Px>, usize, usize) {
let reg: Vec<u32> = rows.iter().flat_map(|r| r.bytes()).map(|b| u32::from(b - b'0')).collect();
let n = reg.iter().max().map_or(0, |m| m + 1);
(reg, (0..n).map(|k| [k as f32 / 4.0; 3]).collect(), rows[0].len(), rows.len())
}
/// The chain whose sides are regions `a` and `b`, either way round.
fn between(chains: &[Chain], sides: &[(u32, u32)], a: u32, b: u32) -> Vec<Chain> {
chains.iter().zip(sides).filter(|(_, s)| **s == (a, b) || **s == (b, a)).map(|(c, _)| c.clone()).collect()
}
#[test]
fn the_edge_between_two_regions_is_one_chain_between_junctions() {
let (reg, colours, w, h) = regions(&["000111", "000111", "000111", "000111"]);
let c: Vec<Px> = reg.iter().map(|&r| colours[r as usize]).collect();
let (chains, sides) = follow_edges(&reg, &colours, &c, w, h);
let inner = between(&chains, &sides, 0, 1);
assert_eq!(inner.len(), 1, "{chains:?}");
let (pts, closed) = &inner[0];
assert!(!closed);
// From junction to junction (where it meets the canvas edge), one point per crack.
let ends = [pts[0], pts[pts.len() - 1]];
assert!(ends.contains(&[3.0, 0.0]) && ends.contains(&[3.0, 4.0]), "{pts:?}");
assert_eq!(pts.len(), 2 + 4);
assert!(pts.iter().all(|p| p[0] == 3.0), "flat colours: the edge is on the crack: {pts:?}");
// Every crack between two regions, the canvas edge included, is in exactly one chain.
let cracks: usize = chains.iter().map(|(p, closed)| p.len() - if *closed { 0 } else { 2 }).sum();
assert_eq!(cracks, 4 + 2 * (w + h));
}
#[test]
fn an_island_is_one_closed_chain() {
let (reg, colours, w, h) = regions(&["00000", "01100", "01100", "00000"]);
let c: Vec<Px> = reg.iter().map(|&r| colours[r as usize]).collect();
let (chains, sides) = follow_edges(&reg, &colours, &c, w, h);
let island = between(&chains, &sides, 0, 1);
assert_eq!(island.len(), 1);
assert!(island[0].1, "closed");
assert_eq!(island[0].0.len(), 8, "one point per crack around the 2x2 island");
}
#[test]
fn an_anti_aliased_edge_is_placed_inside_its_pixel() {
// Region 0 is black and region 1 white; the last column of region 0 is half white, so
// the edge runs down the middle of that column.
let (reg, _, w, h) = regions(&["000111", "000111", "000111"]);
let colours = vec![[0.0; 3], [1.0; 3]];
let c: Vec<Px> = reg.iter().enumerate().map(|(i, &r)| if i % w == 2 { [0.5; 3] } else { colours[r as usize] }).collect();
let (chains, sides) = follow_edges(&reg, &colours, &c, w, h);
let inner = between(&chains, &sides, 0, 1);
let pts = &inner[0].0;
assert!(pts[1..pts.len() - 1].iter().all(|p| (p[0] - 2.5).abs() < 1e-5), "{pts:?}");
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Fill: exact-area scan conversion of the traced shapes, so edges are anti-aliased and shapes
//! that share an edge sum to full coverage.
use super::Pt;
/// Accumulates the signed area of segment `a`–`b` into `acc` (rows of `stride` cells, `h` rows
/// visible, columns `0..stride - 2` visible): the part left of the tile is drawn down its left
/// edge (it still winds the pixels to its right), the part right of it is dropped.
pub(super) fn line_clipped(acc: &mut [f32], stride: usize, h: usize, a: Pt, b: Pt) {
if !(a[0].is_finite() && a[1].is_finite() && b[0].is_finite() && b[1].is_finite()) || a[1] == b[1] {
return;
}
let wf = (stride - 2) as f32;
// At most the two ends and two crossings; on the stack, as this runs for every segment drawn.
let mut buf = [0.0f32, 1.0, 0.0, 0.0];
let mut n = 2;
for x in [0.0, wf] {
let t = (x - a[0]) / (b[0] - a[0]);
if t > 0.0 && t < 1.0 {
buf[n] = t;
n += 1;
}
}
let cuts = &mut buf[..n];
cuts.sort_by(f32::total_cmp);
for t in cuts.windows(2) {
let p = [a[0] + (b[0] - a[0]) * t[0], a[1] + (b[1] - a[1]) * t[0]];
let q = [a[0] + (b[0] - a[0]) * t[1], a[1] + (b[1] - a[1]) * t[1]];
let mx = (p[0] + q[0]) / 2.0;
if mx >= wf {
continue;
}
if mx < 0.0 {
line(acc, stride, h, [0.0, p[1]], [0.0, q[1]]);
} else {
line(acc, stride, h, [p[0].clamp(0.0, wf), p[1]], [q[0].clamp(0.0, wf), q[1]]);
}
}
}
/// Signed-area accumulation of one segment inside the tile (the scanline method of font
/// rasterisers): after a running sum along each row, a cell holds the coverage winding.
fn line(acc: &mut [f32], stride: usize, h: usize, p0: Pt, p1: Pt) {
if p0[1] == p1[1] {
return;
}
let (dir, p0, p1) = if p0[1] < p1[1] { (1.0f32, p0, p1) } else { (-1.0, p1, p0) };
let dxdy = (p1[0] - p0[0]) / (p1[1] - p0[1]);
let mut x = p0[0];
let ystart = p0[1].max(0.0);
if p0[1] < 0.0 {
x -= p0[1] * dxdy;
}
let yend = p1[1].min(h as f32);
if ystart >= yend {
return;
}
let max_x = (stride - 1) as f32;
for y in (ystart as usize)..(yend.ceil() as usize).min(h) {
let ls = y * stride;
let dy = ((y + 1) as f32).min(p1[1]) - (y as f32).max(p0[1]);
let xnext = x + dxdy * dy;
let d = dy * dir;
let (x0, x1) = if x < xnext { (x, xnext) } else { (xnext, x) };
let (x0, x1) = (x0.clamp(0.0, max_x - 1.0), x1.clamp(0.0, max_x - 1.0));
let x0f = x0.floor();
let x0i = x0f as usize;
let x1c = x1.ceil();
let x1i = x1c as usize;
if x1i <= x0i + 1 {
let xmf = 0.5 * (x0 + x1) - x0f;
acc[ls + x0i] += d - d * xmf;
acc[ls + x0i + 1] += d * xmf;
} else {
let sx = (x1 - x0).recip();
let x0r = x0 - x0f;
let a0 = 0.5 * sx * (1.0 - x0r) * (1.0 - x0r);
let x1r = x1 - x1c + 1.0;
let am = 0.5 * sx * x1r * x1r;
acc[ls + x0i] += d * a0;
if x1i == x0i + 2 {
acc[ls + x0i + 1] += d * (1.0 - a0 - am);
} else {
let a1 = sx * (1.5 - x0r);
acc[ls + x0i + 1] += d * (a1 - a0);
for xi in x0i + 2..x1i - 1 {
acc[ls + xi] += d * sx;
}
let a2 = a1 + (x1i - x0i - 3) as f32 * sx;
acc[ls + x1i - 1] += d * (1.0 - a2 - am);
}
acc[ls + x1i] += d * am;
}
x = xnext;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_square_covers_its_area_exactly() {
let mut acc = vec![0.0f32; 12 * 10];
let sq = [[2.25, 2.5], [7.75, 2.5], [7.75, 6.5], [2.25, 6.5], [2.25, 2.5]];
for s in sq.windows(2) {
line_clipped(&mut acc, 12, 10, s[0], s[1]);
}
let mut total = 0.0;
for y in 0..10 {
let mut sum = 0.0;
for x in 0..10 {
sum += acc[y * 12 + x];
total += sum.abs().min(1.0);
}
}
assert!((total - 5.5 * 4.0).abs() < 1e-4, "{total}");
}
}
+352
View File
@@ -0,0 +1,352 @@
//! Cutout (Filter Gallery › Artistic): the image as flat sheets of cut paper.
//!
//! The image is traced into shapes, and the shapes are filled back in:
//!
//! 1. **Smooth** within tones, edges kept: a bilateral filter (C. Tomasi and R. Manduchi,
//! "Bilateral Filtering for Gray and Color Images", 1998) in Oklab, so a photo's noise doesn't
//! decide pixel by pixel which of two colours a pixel gets.
//! 2. **Palette**: median cut (Heckbert 1982) then k-means over the smoothed colours, Number of
//! Levels tones of the picture's own; a tone made of two clearly different hues splits in two,
//! and colours only noise apart are merged.
//! 3. **Regions**: 4-connected areas of one palette colour. Regions smaller than the Edge
//! Simplicity area, or thin slivers (the blends along an edge between two other colours), are
//! merged smallest first into the neighbour they share most border with. Each region is filled
//! with the mean colour of its own pixels.
//! 4. **Edges**: the cracks between regions are linked into chains from junction to junction
//! (where three regions meet, or the canvas edge). Each crack is moved along its normal to where
//! the pixels on either side say the edge is (an anti-aliased pixel is part one colour, part the
//! other), so outlines are right to a fraction of a pixel. A chain is fitted once, so two
//! neighbouring shapes share one and the same edge.
//! 5. **Curves**: turns sharper than Edge Fidelity allows are kept as corners (squared back where
//! the pixels rounded them); the runs between are simplified to the Edge Simplicity tolerance
//! (Douglas–Peucker) and drawn as Catmull-Rom curves.
//! 6. **Fill**: each region is scan-converted with exact area coverage, so edges are anti-aliased
//! and the coverages of shapes that share an edge sum to one: no seams.
//!
//! Tracing reads the whole image and runs once; filling runs per tile and gives the same pixels
//! whatever the tiling.
//!
//! Each stage is its own module: [`colour`] (steps 1–2), [`regions`] (3), [`edges`] (4),
//! [`curves`] (5) and [`fill`] (6); [`trace`] runs them in turn.
use photocraft_geom::Rect;
use crate::par;
mod colour;
mod curves;
mod edges;
mod fill;
mod regions;
mod util;
use colour::{nearest, oklab, palette, smooth};
use curves::{bounds, fit};
use edges::follow_edges;
use fill::line_clipped;
use regions::{components, local_paper, merge_regions, paper_colours};
type Px = [f32; 3];
type Pt = [f32; 2];
/// Region id of the area outside the image.
const OUT: u32 = u32::MAX;
/// The filter's three sliders.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Settings {
/// Number of Levels (2–8): the most colours the palette has.
pub levels: usize,
/// Edge Simplicity (0–10): smaller shapes merge and outlines simplify more.
pub simplicity: f32,
/// Edge Fidelity (1–3): more corners are kept.
pub fidelity: f32,
}
impl Settings {
/// Regions with fewer pixels merge into a neighbour.
fn min_area(&self) -> u32 {
(1.0 + self.simplicity.clamp(0.0, 10.0)).powi(2).round() as u32
}
/// Regions thinner than this (twice the area over the perimeter, in pixels) merge too.
fn sliver(&self) -> f32 {
0.6 + 0.1 * self.simplicity.clamp(0.0, 10.0)
}
/// Douglas–Peucker tolerance of the outlines, in pixels: looser with less Edge Fidelity
/// (twice as loose at 1, half at 3).
fn tolerance(&self) -> f32 {
(0.5 + 0.12 * self.simplicity.clamp(0.0, 10.0)) * 2f32.powf(2.0 - self.fidelity.clamp(1.0, 3.0))
}
/// Below Edge Fidelity 2 the outline is rounded off before it is fitted: points averaged with
/// this many neighbours either side (3 at fidelity 1).
fn rounding(&self) -> usize {
((2.0 - self.fidelity.clamp(1.0, 3.0)) * 3.0).round().max(0.0) as usize
}
/// Turns sharper than this stay corners.
fn corner_turn(&self) -> f32 {
(100.0 - 22.5 * self.fidelity.clamp(1.0, 3.0)).to_radians()
}
/// Rounds of edge-preserving smoothing.
fn smooth_rounds(&self) -> usize {
2 + (self.simplicity.clamp(0.0, 10.0) / 4.0) as usize
}
}
/// A traced image: the shapes and their colours, ready to fill any tile.
pub(crate) struct Traced {
/// Document position of the traced window's top-left pixel; outlines are relative to it.
x0: i32,
y0: i32,
/// Fitted edges (polylines), each shared by the one or two regions it separates.
chains: Vec<Vec<Pt>>,
/// Per chain, its bounding box `[x0, y0, x1, y1]`.
chain_boxes: Vec<[f32; 4]>,
regions: Vec<Region>,
/// Width of the traced window, each pixel's region, and its paper colour (the region's
/// lightness with the hue of its own pixels nearby).
width: usize,
reg: Vec<u32>,
field: Vec<Px>,
}
/// One shape: its flat colour, bounding box and outline.
struct Region {
colour: Px,
bbox: [f32; 4],
/// The chains around the region, reversed where the region is on their right.
edges: Vec<(u32, bool)>,
}
/// Traces `c` (sRGB colour of every pixel of `r`, row-major).
pub(crate) fn trace(c: &[Px], r: Rect, s: &Settings) -> Traced {
let (w, h) = (r.width() as usize, r.height() as usize);
let empty = Traced { x0: r.x0, y0: r.y0, chains: Vec::new(), chain_boxes: Vec::new(), regions: Vec::new(), width: 0, reg: Vec::new(), field: Vec::new() };
if w == 0 || h == 0 || c.len() != w * h {
return empty;
}
let mut lab = vec![[0.0f32; 3]; w * h];
par::chunks_mut(&mut lab, w, true, |y, row| {
for (o, p) in row.iter_mut().zip(&c[y * w..(y + 1) * w]) {
*o = oklab(*p);
}
});
let original = lab.clone();
let lab = smooth(lab, w, h, s.smooth_rounds());
let palette = palette(&lab, s.levels.clamp(2, 8));
let mut labels = vec![0u8; w * h];
par::chunks_mut(&mut labels, w, true, |y, row| {
for (o, p) in row.iter_mut().zip(&lab[y * w..(y + 1) * w]) {
*o = nearest(&palette, *p);
}
});
let (comp, n) = components(&labels, w, h);
let (reg, colours) = merge_regions(&comp, n, c, w, h, s);
let (chains, sides) = follow_edges(&reg, &colours, c, w, h);
let chains: Vec<Vec<Pt>> = par::map(chains.len(), true, |i| fit(&chains[i].0, chains[i].1, s));
let chain_boxes: Vec<[f32; 4]> = chains.iter().map(|ch| bounds(ch)).collect();
let paint = paper_colours(&reg, colours.len(), &original);
let field = local_paper(&reg, &paint, &original, w, h, 3.0 + 1.2 * s.simplicity.clamp(0.0, 10.0));
let mut regions: Vec<Region> =
paint.into_iter().map(|colour| Region { colour, bbox: [f32::MAX, f32::MAX, f32::MIN, f32::MIN], edges: Vec::new() }).collect();
for (k, &(left, right)) in sides.iter().enumerate() {
for (side, rev) in [(left, false), (right, true)] {
if let Some(g) = regions.get_mut(side as usize) {
g.edges.push((k as u32, rev));
let b = chain_boxes[k];
g.bbox = [g.bbox[0].min(b[0]), g.bbox[1].min(b[1]), g.bbox[2].max(b[2]), g.bbox[3].max(b[3])];
}
}
}
Traced { x0: r.x0, y0: r.y0, chains, chain_boxes, regions, width: w, reg, field }
}
impl Traced {
/// The cut paper over `r` (document coordinates): sRGB colour per pixel, row-major.
pub(crate) fn render(&self, r: Rect) -> Vec<Px> {
let (w, h) = (r.width() as usize, r.height() as usize);
let mut col = vec![[0.0f32; 3]; w * h];
if w == 0 || h == 0 {
return col;
}
let mut weight = vec![0.0f32; w * h];
let stride = w + 2;
let mut acc = vec![0.0f32; stride * h];
let (ox, oy) = ((self.x0 - r.x0) as f32, (self.y0 - r.y0) as f32);
let (wf, hf) = (w as f32, h as f32);
let (dx, dy) = ((r.x0 - self.x0) as i64, (r.y0 - self.y0) as i64);
let th = self.reg.len() / self.width.max(1);
for (gi, g) in self.regions.iter().enumerate() {
let b = [g.bbox[0] + ox, g.bbox[1] + oy, g.bbox[2] + ox, g.bbox[3] + oy];
if b[2] <= 0.0 || b[0] >= wf || b[3] <= 0.0 || b[1] >= hf {
continue;
}
for &(k, rev) in &g.edges {
let (Some(pts), Some(cb)) = (self.chains.get(k as usize), self.chain_boxes.get(k as usize)) else { continue };
// Chains left of the tile still count (they wind the rows they cross); chains to
// its right or above or below it don't.
if cb[0] + ox >= wf || cb[3] + oy <= 0.0 || cb[1] + oy >= hf {
continue;
}
for seg in pts.windows(2) {
let (a, z) = if rev { (seg[1], seg[0]) } else { (seg[0], seg[1]) };
line_clipped(&mut acc, stride, h, [a[0] + ox, a[1] + oy], [z[0] + ox, z[1] + oy]);
}
}
let y0 = b[1].floor().max(0.0) as usize;
let y1 = (b[3].ceil().max(0.0) as usize).min(h);
let xe = ((b[2].ceil().max(0.0) as usize) + 1).min(w);
for y in y0..y1 {
let row = &mut acc[y * stride..(y + 1) * stride];
let mut sum = 0.0f32;
for (x, v) in row.iter().enumerate().take(xe) {
sum += v;
let cov = sum.abs().min(1.0);
if cov > 0.0 {
let i = y * w + x;
// Inside its own pixels a shape takes the local colour; where it reaches
// into a neighbour's edge pixel, its own flat colour.
let (tx, ty) = (x as i64 + dx, y as i64 + dy);
let t = (tx >= 0 && ty >= 0 && (tx as usize) < self.width && (ty as usize) < th).then(|| ty as usize * self.width + tx as usize);
let paint = match t {
Some(t) if self.reg[t] as usize == gi => self.field[t],
_ => g.colour,
};
col[i] = [col[i][0] + paint[0] * cov, col[i][1] + paint[1] * cov, col[i][2] + paint[2] * cov];
weight[i] += cov;
}
}
row.fill(0.0);
}
}
for (p, &wt) in col.iter_mut().zip(&weight) {
if wt > 1e-6 {
*p = [p[0] / wt, p[1] / wt, p[2] / wt];
}
}
col
}
}
#[cfg(test)]
mod tests {
use super::*;
use colour::dist2;
fn settings(levels: usize, simplicity: f32, fidelity: f32) -> Settings {
Settings { levels, simplicity, fidelity }
}
/// Total coverage of every pixel: each pixel is covered exactly once by the shapes.
fn coverage(t: &Traced, r: Rect) -> Vec<f32> {
let (w, h) = (r.width() as usize, r.height() as usize);
let mut weight = vec![0.0f32; w * h];
let stride = w + 2;
let mut acc = vec![0.0f32; stride * h];
for g in &t.regions {
for &(k, rev) in &g.edges {
for seg in t.chains[k as usize].windows(2) {
let (a, z) = if rev { (seg[1], seg[0]) } else { (seg[0], seg[1]) };
line_clipped(&mut acc, stride, h, a, z);
}
}
for y in 0..h {
let mut sum = 0.0;
for x in 0..w {
sum += acc[y * stride + x];
weight[y * w + x] += sum.abs().min(1.0);
}
acc[y * stride..(y + 1) * stride].fill(0.0);
}
}
weight
}
#[test]
fn shapes_cover_every_pixel_once_with_no_seams() {
// Blobs of four colours touching each other and the canvas edge, with T-junctions,
// saddles and holes.
let (w, h) = (48usize, 36usize);
let pal = [[0.1, 0.1, 0.1], [0.9, 0.2, 0.2], [0.2, 0.7, 0.3], [0.95, 0.95, 0.9]];
let c: Vec<Px> = (0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let k = ((x / 7.0).sin() + (y / 5.0).cos() + (x * y / 90.0).sin()).abs() * 1.6;
pal[(k as usize).min(3)]
})
.collect();
let r = Rect::new(0, 0, w as i32, h as i32);
for fidelity in [1.0, 3.0] {
let t = trace(&c, r, &settings(4, 2.0, fidelity));
assert!(t.regions.len() > 4);
let cov = coverage(&t, r);
let worst = cov.iter().map(|v| (v - 1.0).abs()).fold(0.0, f32::max);
assert!(worst < 1e-3, "coverage off by {worst}");
}
}
#[test]
fn edge_fidelity_changes_the_outlines() {
// It used to set only which bends count as corners: on smooth shapes 0.5% of pixels
// changed from fidelity 1 to 2 and 2.8% from 2 to 3 (now about 9% and 5%).
// Large smooth shapes, as in a photo: few corners, long curved outlines.
let (w, h) = (160usize, 120usize);
let pal = [[0.1, 0.1, 0.1], [0.9, 0.3, 0.2], [0.3, 0.6, 0.9]];
let c: Vec<Px> = (0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let k = ((x / 17.0).sin() * 1.3 + (y / 13.0).cos() + (x * y / 900.0).sin()).abs();
pal[(k as usize).min(2)]
})
.collect();
let r = Rect::new(0, 0, w as i32, h as i32);
let out = |f: f32| trace(&c, r, &settings(3, 2.0, f)).render(r);
let changed = |a: &[Px], b: &[Px]| a.iter().zip(b).filter(|(p, q)| dist2(**p, **q) > 0.01).count() as f32 / a.len() as f32;
let (loose, normal, tight) = (out(1.0), out(2.0), out(3.0));
assert!(changed(&loose, &normal) > 0.04, "fidelity 1 vs 2: {}", changed(&loose, &normal));
assert!(changed(&normal, &tight) > 0.04, "fidelity 2 vs 3: {}", changed(&normal, &tight));
}
#[test]
fn empty_images_stop_before_the_transpose_and_row_passes() {
for r in [Rect::new(0, 0, 0, 4), Rect::new(0, 0, 4, 0), Rect::new(0, 0, 0, 0)] {
let t = trace(&[], r, &settings(4, 2.0, 1.0));
assert!(t.regions.is_empty());
assert!(t.chains.is_empty());
assert!(t.field.is_empty());
}
}
#[test]
fn filling_does_not_depend_on_the_tiling() {
let (w, h) = (40usize, 30usize);
let c: Vec<Px> = (0..w * h).map(|i| if ((i % w) / 6 + (i / w) / 5) % 3 == 0 { [0.8, 0.3, 0.1] } else { [0.1, 0.3, 0.7] }).collect();
let r = Rect::new(10, 20, 10 + w as i32, 20 + h as i32);
let t = trace(&c, r, &settings(3, 1.0, 2.0));
let whole = t.render(r);
for tile in [Rect::new(10, 20, 23, 31), Rect::new(23, 31, 50, 50), Rect::new(37, 20, 50, 33)] {
let part = t.render(tile);
for y in tile.y0..tile.y1 {
for x in tile.x0..tile.x1 {
let a = whole[((y - r.y0) as usize) * w + (x - r.x0) as usize];
let b = part[((y - tile.y0) as usize) * tile.width() as usize + (x - tile.x0) as usize];
assert!(dist2(a, b) < 1e-8, "({x}, {y}): {a:?} vs {b:?}");
}
}
}
}
#[test]
fn noise_merges_into_neighbours_and_noise_apart_colours_are_one() {
let (w, h) = (32usize, 32usize);
let c: Vec<Px> = (0..w * h)
.map(|i| {
let v = 0.3 + crate::photo_util::hash01((i % w) as i64, (i / w) as i64, 5) * 0.06;
if (11..14).contains(&(i % w)) && (11..14).contains(&(i / w)) { [0.9, 0.9, 0.9] } else { [v, v, v] }
})
.collect();
let r = Rect::new(0, 0, w as i32, h as i32);
assert_eq!(trace(&c, r, &settings(6, 0.0, 2.0)).regions.len(), 2, "the speck stays at simplicity 0");
assert_eq!(trace(&c, r, &settings(6, 10.0, 2.0)).regions.len(), 1, "the speck merges at simplicity 10");
}
}
+357
View File
@@ -0,0 +1,357 @@
//! Regions: connected areas of one palette colour, the merge of small ones and slivers, and the
//! paper colour of each region.
use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap};
use std::hash::BuildHasherDefault;
use super::colour::{in_gamut, oklab, srgb};
use super::util::{FxHasher, find, transpose, transpose_u32};
use super::{Px, Settings};
use crate::par;
/// 4-connected components of equal labels, numbered in raster order of their first pixel.
pub(super) fn components(labels: &[u8], w: usize, h: usize) -> (Vec<u32>, usize) {
let mut comp = vec![0u32; w * h];
let mut parent: Vec<u32> = Vec::new();
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let up = (y > 0 && labels[i - w] == labels[i]).then(|| comp[i - w]);
let left = (x > 0 && labels[i - 1] == labels[i]).then(|| comp[i - 1]);
comp[i] = match (up, left) {
(Some(a), Some(b)) => {
let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
let (lo, hi) = (ra.min(rb), ra.max(rb));
parent[hi as usize] = lo;
lo
}
(Some(a), None) | (None, Some(a)) => a,
(None, None) => {
parent.push(parent.len() as u32);
parent.len() as u32 - 1
}
};
}
}
let mut index = vec![u32::MAX; parent.len()];
let mut n = 0u32;
for c in comp.iter_mut() {
let root = find(&mut parent, *c) as usize;
if index[root] == u32::MAX {
index[root] = n;
n += 1;
}
*c = index[root];
}
(comp, n as usize)
}
/// Merges small and sliver components into their neighbours. Returns each pixel's region and
/// each region's mean colour.
pub(super) fn merge_regions(comp: &[u32], n: usize, c: &[Px], w: usize, h: usize, s: &Settings) -> (Vec<u32>, Vec<Px>) {
let mut area = vec![0u32; n];
let mut sum = vec![[0.0f64; 3]; n];
for (&k, p) in comp.iter().zip(c) {
let k = k as usize;
area[k] += 1;
sum[k] = [sum[k][0] + f64::from(p[0]), sum[k][1] + f64::from(p[1]), sum[k][2] + f64::from(p[2])];
}
// Shared border lengths (in cracks) and perimeters, the canvas edge included.
let mut shared: HashMap<u64, u32, BuildHasherDefault<FxHasher>> = HashMap::default();
let mut perim = vec![0u32; n];
for y in 0..h {
for x in 0..w {
let a = comp[y * w + x];
for (nx, ny) in [(x + 1, y), (x, y + 1)] {
if nx < w && ny < h {
let b = comp[ny * w + nx];
if a != b {
*shared.entry((u64::from(a.min(b)) << 32) | u64::from(a.max(b))).or_insert(0) += 1;
}
}
}
perim[a as usize] += u32::from(x == 0) + u32::from(x + 1 == w) + u32::from(y == 0) + u32::from(y + 1 == h);
}
}
let mut adj: Vec<Vec<(u32, u32)>> = vec![Vec::new(); n];
let mut pairs: Vec<(u64, u32)> = shared.into_iter().collect();
pairs.sort_unstable();
for (key, len) in pairs {
let (a, b) = ((key >> 32) as u32, key as u32);
adj[a as usize].push((b, len));
adj[b as usize].push((a, len));
perim[a as usize] += len;
perim[b as usize] += len;
}
let (min_area, sliver) = (s.min_area(), s.sliver());
let mergeable = |a: u32, p: u32| a < min_area || 2.0 * a as f32 / (p.max(1) as f32) < sliver;
let mut parent: Vec<u32> = (0..n as u32).collect();
let mut heap: BinaryHeap<Reverse<(u32, u32)>> = (0..n).filter(|&i| mergeable(area[i], perim[i])).map(|i| Reverse((area[i], i as u32))).collect();
while let Some(Reverse((a, id))) = heap.pop() {
let i = id as usize;
if find(&mut parent, id) != id || area[i] != a || !mergeable(a, perim[i]) {
continue;
}
// The neighbours as they are now, with their total shared border.
let mut nb: Vec<(u32, u32)> = std::mem::take(&mut adj[i]).into_iter().map(|(k, len)| (find(&mut parent, k), len)).filter(|&(k, _)| k != id).collect();
nb.sort_unstable();
nb.dedup_by(|b, a| {
if a.0 == b.0 {
a.1 += b.1;
true
} else {
false
}
});
let Some(&(t, len)) = nb.iter().max_by(|a, b| a.1.cmp(&b.1).then(b.0.cmp(&a.0))) else {
adj[i] = nb;
continue;
};
let ti = t as usize;
parent[i] = t;
area[ti] += area[i];
sum[ti] = [sum[ti][0] + sum[i][0], sum[ti][1] + sum[i][1], sum[ti][2] + sum[i][2]];
perim[ti] = (perim[ti] + perim[i]).saturating_sub(2 * len);
// Appended as they are; resolved and summed when `t` itself merges.
adj[ti].extend(nb.into_iter().filter(|&(k, _)| k != t));
if mergeable(area[ti], perim[ti]) {
heap.push(Reverse((area[ti], t)));
}
}
let mut index = vec![u32::MAX; n];
let mut colours = Vec::new();
let mut reg = vec![0u32; comp.len()];
for (r, &k) in reg.iter_mut().zip(comp) {
let root = find(&mut parent, k) as usize;
if index[root] == u32::MAX {
index[root] = colours.len() as u32;
let a = f64::from(area[root].max(1));
colours.push([(sum[root][0] / a) as f32, (sum[root][1] / a) as f32, (sum[root][2] / a) as f32]);
}
*r = index[root];
}
(reg, colours)
}
/// The paper colour of every region. A plain average of a region's pixels greys it (a face of
/// pink and cream patches averages to beige, hues cancel), and a whole image of averages sits in
/// the middle tones. So in Oklab each region keeps its average hue at the average *strength* of
/// its pixels' colour, and lightness is stretched over the image's regions so the darkest paper is
/// near black and the lightest near white. Out-of-gamut colours lose chroma, not hue.
pub(super) fn paper_colours(reg: &[u32], n: usize, lab: &[Px]) -> Vec<Px> {
let mut acc = vec![[0.0f64; 5]; n];
for (&r, p) in reg.iter().zip(lab) {
if let Some(a) = acc.get_mut(r as usize) {
let chroma = (p[1] * p[1] + p[2] * p[2]).sqrt();
*a = [a[0] + f64::from(p[0]), a[1] + f64::from(p[1]), a[2] + f64::from(p[2]), a[3] + f64::from(chroma), a[4] + 1.0];
}
}
let mean: Vec<(Px, f32, f64)> = acc
.iter()
.map(|a| {
let k = a[4].max(1.0);
([(a[0] / k) as f32, (a[1] / k) as f32, (a[2] / k) as f32], (a[3] / k) as f32, a[4])
})
.collect();
// The lightness range of the image's paper, by area (ignoring the extreme 1% at each end).
let mut by_l: Vec<(f32, f64)> = mean.iter().map(|m| (m.0[0], m.2)).collect();
by_l.sort_by(|a, b| a.0.total_cmp(&b.0));
let total: f64 = by_l.iter().map(|m| m.1).sum();
let at = |q: f64| {
let mut run = 0.0;
for &(l, a) in &by_l {
run += a;
if run >= q * total {
return l;
}
}
by_l.last().map_or(0.5, |m| m.0)
};
let (lo, hi) = (at(0.01), at(0.99));
const STRETCH: f32 = 0.75;
mean.iter()
.map(|(m, chroma, _)| {
let ab = (m[1] * m[1] + m[2] * m[2]).sqrt();
let k = if ab > 1e-5 { chroma / ab } else { 1.0 };
let stretched = if hi - lo > 0.05 { m[0] + (0.04 + (m[0] - lo) / (hi - lo) * 0.88 - m[0]) * STRETCH } else { m[0] };
let stretched = stretched.clamp(0.0, 1.0);
let colour = |l: f32, t: f32| srgb([l, m[1] * k * t, m[2] * k * t]);
if let Some(c) = colour(stretched, 1.0) {
return c;
}
// Colour comes before contrast: a strong colour the stretch took out of gamut (a
// bright blue pushed towards white) moves back towards its own lightness first.
let own = m[0].clamp(0.0, 1.0);
if colour(own, 1.0).is_some() {
let (mut ok, mut bad) = (own, stretched);
for _ in 0..12 {
let l = (ok + bad) / 2.0;
if colour(l, 1.0).is_some() { ok = l } else { bad = l }
}
return colour(ok, 1.0).unwrap_or([ok, ok, ok]);
}
// Even its own lightness can't hold it: keep the stretch, lose chroma (not hue).
let (mut ok, mut bad) = (0.0f32, 1.0f32);
for _ in 0..12 {
let t = (ok + bad) / 2.0;
if colour(stretched, t).is_some() { ok = t } else { bad = t }
}
colour(stretched, ok).unwrap_or([stretched, stretched, stretched])
})
.collect()
}
/// Each pixel's paper colour: its region's lightness (flat across the shape) with the average
/// hue, at the average strength, of the region's own pixels within about `reach` pixels. Colour
/// can drift gently across a large shape (pink cheeks on a cream face) but never crosses a cut.
/// `paper` is each region's colour (from [`paper_colours`]).
pub(super) fn local_paper(reg: &[u32], paper: &[Px], lab: &[Px], w: usize, h: usize, reach: f32) -> Vec<Px> {
let mut v: Vec<Px> = lab.iter().map(|p| [p[1], p[2], (p[1] * p[1] + p[2] * p[2]).sqrt()]).collect();
let regt = transpose_u32(reg, w, h);
let a = (-1.0 / reach.max(0.5)).exp();
for _ in 0..2 {
same_region_rows(&mut v, reg, w, a);
let mut t = transpose(&v, w, h);
same_region_rows(&mut t, &regt, h, a);
v = transpose(&t, h, w);
}
let light: Vec<f32> = paper.iter().map(|c| oklab(*c)[0]).collect();
let mut out = vec![[0.0f32; 3]; w * h];
par::chunks_mut(&mut out, w, true, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = y * w + x;
let (q, l) = (v[i], light.get(reg[i] as usize).copied().unwrap_or(0.5));
let ab = (q[0] * q[0] + q[1] * q[1]).sqrt();
let k = if ab > 1e-5 { q[2] / ab } else { 1.0 };
*o = in_gamut(l, q[0] * k, q[1] * k);
}
});
out
}
/// Exponential smoothing along each row (both ways) that restarts at every change of region.
fn same_region_rows(v: &mut [Px], reg: &[u32], w: usize, a: f32) {
let mut chunks: Vec<(&mut [Px], &[u32])> = v.chunks_mut(w).zip(reg.chunks(w)).collect();
let f = |(row, rg): &mut (&mut [Px], &[u32])| {
let n = row.len();
let src = row.to_vec();
let mut fwd = vec![([0.0f32; 3], 0.0f32); n];
for x in 0..n {
let (p, d) = if x > 0 && rg[x] == rg[x - 1] { fwd[x - 1] } else { ([0.0; 3], 0.0) };
fwd[x] = ([src[x][0] + a * p[0], src[x][1] + a * p[1], src[x][2] + a * p[2]], 1.0 + a * d);
}
let mut back = ([0.0f32; 3], 0.0f32);
for x in (0..n).rev() {
if x + 1 < n && rg[x] != rg[x + 1] {
back = ([0.0; 3], 0.0);
}
back = ([src[x][0] + a * back.0[0], src[x][1] + a * back.0[1], src[x][2] + a * back.0[2]], 1.0 + a * back.1);
let den = fwd[x].1 + back.1 - 1.0;
row[x] = std::array::from_fn(|c| (fwd[x].0[c] + back.0[c] - src[x][c]) / den);
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
chunks.par_iter_mut().for_each(f);
}
#[cfg(target_arch = "wasm32")]
chunks.iter_mut().for_each(f);
}
#[cfg(test)]
mod tests {
use super::*;
fn settings(simplicity: f32) -> Settings {
Settings { levels: 4, simplicity, fidelity: 2.0 }
}
/// Labels drawn as text, one character per pixel.
fn labels(rows: &[&str]) -> (Vec<u8>, usize, usize) {
(rows.iter().flat_map(|r| r.bytes()).collect(), rows[0].len(), rows.len())
}
#[test]
fn components_are_four_connected_and_numbered_in_raster_order() {
let (l, w, h) = labels(&["aab", "abb", "bba"]);
let (comp, n) = components(&l, w, h);
// The top-left `a`s, the `b`s (one area), then the lone `a` (touching the first only
// diagonally), in the order their first pixels come.
assert_eq!(n, 3);
assert_eq!(comp, vec![0, 0, 1, 0, 1, 1, 1, 1, 2]);
// A U shape: both arms are found as separate runs first and joined further down.
let (l, w, h) = labels(&["a.a", "a.a", "aaa"]);
let (comp, n) = components(&l, w, h);
assert_eq!(n, 2);
assert_eq!(comp[0], comp[2]);
}
/// Regions from labels, with one flat colour per label.
fn merged(rows: &[&str], simplicity: f32) -> (Vec<u32>, Vec<Px>) {
let (l, w, h) = labels(rows);
let c: Vec<Px> = l.iter().map(|&b| [f32::from(b - b'a') / 4.0; 3]).collect();
let (comp, n) = components(&l, w, h);
merge_regions(&comp, n, &c, w, h, &settings(simplicity))
}
#[test]
fn small_regions_merge_into_the_neighbour_they_share_most_border_with() {
let rows = ["aaaaaaaa", "aaaaaaaa", "aacaaaaa", "aaccbbbb", "aaaabbbb", "aaaabbbb", "aaaabbbb"];
// At Edge Simplicity 0 every region is kept.
assert_eq!(merged(&rows, 0.0).1.len(), 3);
// At 1 (regions under four pixels merge) the three `c`s go into the `a`s, which they share seven
// cracks with (one with the `b`s).
let (reg, colours) = merged(&rows, 1.0);
assert_eq!(colours.len(), 2);
assert_eq!(reg[2 * 8 + 2], reg[0]);
assert_ne!(reg[3 * 8 + 4], reg[0]);
// A region's colour is the mean of its pixels: 37 `a`s (0.0) and 3 `c`s (0.5).
let a = colours[reg[0] as usize][0];
assert!((a - 1.5 / 40.0).abs() < 1e-6, "{a}");
}
#[test]
fn slivers_merge_though_they_are_large() {
// A line one pixel wide between two areas: the blend along an edge, not a shape.
let rows: Vec<String> = (0..40).map(|_| format!("{}c{}", "a".repeat(10), "b".repeat(9))).collect();
let rows: Vec<&str> = rows.iter().map(String::as_str).collect();
assert_eq!(merged(&rows, 0.0).1.len(), 3, "kept at Edge Simplicity 0");
let (_, colours) = merged(&rows, 4.0);
assert_eq!(colours.len(), 2, "40 pixels is over the 25 that merge at 4, but it is a sliver");
}
#[test]
fn paper_is_stretched_towards_black_and_white_and_keeps_its_hue() {
// Three regions: a dark and a light grey, and a mid-tone orange.
let lab = [[0.35, 0.0, 0.0], [0.65, 0.0, 0.0], [0.5, 0.06, 0.05]];
let reg: Vec<u32> = (0..300).map(|i| (i / 100) as u32).collect();
let px: Vec<Px> = reg.iter().map(|&r| lab[r as usize]).collect();
let paper: Vec<Px> = paper_colours(&reg, 3, &px).into_iter().map(oklab).collect();
assert!(paper[0][0] < 0.25 && paper[1][0] > 0.75, "{paper:?}");
assert!((paper[2][0] - 0.5).abs() < 0.05, "a middle tone stays in the middle: {paper:?}");
assert!((paper[2][2] / paper[2][1] - 0.05 / 0.06).abs() < 0.05, "hue kept: {paper:?}");
assert!(paper[0][1].abs() < 1e-3 && paper[0][2].abs() < 1e-3, "grey stays grey: {paper:?}");
}
#[test]
fn local_colour_drifts_within_a_shape_but_never_crosses_a_cut() {
// One light region, pink on the left and cream on the right, next to a blue one.
let (w, h) = (30usize, 4usize);
let reg: Vec<u32> = (0..w * h).map(|i| u32::from(i % w >= 20)).collect();
let lab: Vec<Px> = (0..w * h)
.map(|i| match i % w {
0..10 => [0.8, 0.08, 0.02],
10..20 => [0.8, 0.01, 0.04],
_ => [0.4, -0.02, -0.12],
})
.collect();
let paper = [srgb([0.8, 0.04, 0.03]).unwrap_or_default(), srgb([0.4, -0.02, -0.12]).unwrap_or_default()];
let out: Vec<Px> = local_paper(&reg, &paper, &lab, w, h, 3.0).into_iter().map(oklab).collect();
let (left, right, edge) = (out[w + 2], out[w + 17], out[w + 19]);
assert!(left[1] > right[1] + 0.03, "the pink side is pinker: {left:?} vs {right:?}");
assert!((left[0] - right[0]).abs() < 0.01, "one lightness across the shape: {left:?} vs {right:?}");
assert!(edge[2] > 0.0, "no blue from the next shape at the cut: {edge:?}");
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Small helpers shared by the Cutout stages.
use std::hash::Hasher;
use super::Px;
use crate::par;
/// Row-major `w`×`h` colours as `h`×`w` (columns become rows), so a pass along rows can run
/// along columns too.
pub(super) fn transpose(buf: &[Px], w: usize, h: usize) -> Vec<Px> {
let mut out = vec![[0.0f32; 3]; w * h];
par::chunks_mut(&mut out, h, true, |x, col| {
for (y, p) in col.iter_mut().enumerate() {
*p = buf[y * w + x];
}
});
out
}
/// [`transpose`] for region ids.
pub(super) fn transpose_u32(buf: &[u32], w: usize, h: usize) -> Vec<u32> {
let mut out = vec![0u32; w * h];
for (x, col) in out.chunks_mut(h).enumerate() {
for (y, p) in col.iter_mut().enumerate() {
*p = buf[y * w + x];
}
}
out
}
/// Union-find root of `i`, halving the path on the way.
pub(super) fn find(parent: &mut [u32], mut i: u32) -> u32 {
while parent[i as usize] != i {
let p = parent[i as usize];
parent[i as usize] = parent[p as usize];
i = p;
}
i
}
/// A fast deterministic hasher for the region adjacency map.
#[derive(Default)]
pub(super) struct FxHasher(u64);
impl Hasher for FxHasher {
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.write_u64(u64::from(b));
}
}
fn write_u64(&mut self, v: u64) {
self.0 = (self.0.rotate_left(5) ^ v).wrapping_mul(0x517c_c1b7_2722_0a95);
}
fn finish(&self) -> u64 {
self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transposing_twice_gives_the_image_back() {
let (w, h) = (5usize, 3usize);
let c: Vec<Px> = (0..w * h).map(|i| [i as f32, 0.0, 0.0]).collect();
let t = transpose(&c, w, h);
assert_eq!(t[1], c[w], "the second value of the first column is the first of the second row");
assert_eq!(transpose(&t, h, w), c);
let ids: Vec<u32> = (0..(w * h) as u32).collect();
assert_eq!(transpose_u32(&ids, w, h), t.iter().map(|p| p[0] as u32).collect::<Vec<_>>());
assert_eq!(transpose_u32(&transpose_u32(&ids, w, h), h, w), ids);
}
#[test]
fn find_returns_the_root_and_shortens_the_path() {
// 4 -> 3 -> 2 -> 1 -> 0.
let mut parent = vec![0, 0, 1, 2, 3];
assert_eq!(find(&mut parent, 4), 0);
assert_eq!(find(&mut parent, 0), 0);
assert!(parent[4] < 3, "the path was halved: {parent:?}");
assert!(parent.iter().all(|&p| p <= 2));
}
}
+15
View File
@@ -26,6 +26,7 @@ mod blur2;
pub mod camera_raw;
pub mod color_to_alpha;
pub mod content_aware;
mod cutout;
mod denoise;
pub mod dff;
pub mod direct;
@@ -72,6 +73,7 @@ pub mod seam;
pub mod segment;
pub mod selection;
mod selection_blur;
pub mod separable;
mod shape_blur;
mod sharpen;
pub mod stack;
@@ -564,6 +566,9 @@ impl FilterParams {
pub fn halo(&self) -> Halo {
let g = |sigma: f32| Halo::Radius(((sigma.max(0.0) * 3.0).ceil() as i32).saturating_add(1));
match self {
// Small Gaussian radii use the calibrated seven-tap kernel as well: its reach is
// three pixels even when ceil(3 * radius) + 1 would be smaller.
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } if (0.05..1.0).contains(radius) => Halo::Radius(3),
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
FilterParams::SmartSharpen { radius, .. } => g(*radius),
@@ -730,6 +735,7 @@ fn halo_ext(p: &FilterParams) -> Halo {
FilterParams::PathBlur { paths } => r(paths.iter().map(|p| p.speed.abs()).fold(0.0, f32::max) / 2.0 + 1.0),
FilterParams::Custom { .. } => r(2.0),
FilterParams::DeInterlace { .. } => r(1.0),
FilterParams::FilterGallery { effects } if artistic_fx::is_global(effects) => Halo::Bounds,
FilterParams::FilterGallery { effects } => Halo::Radius(artistic_fx::reach(effects)),
_ => Halo::Radius(0),
}
@@ -1120,6 +1126,14 @@ fn apply_tiled_with_impl(
}
}
let shared = (halo == Halo::Bounds).then(|| Image::read(surface, bounds.union(&area)));
// A Filter Gallery stack with a Cutout traces the whole image once; tiles only fill it in.
let gallery = match (params, &shared) {
(FilterParams::FilterGallery { effects }, Some(src)) => artistic_fx::Prepared::new(effects, src, &ctx),
_ => None,
};
if ctl.cancelled() {
return None;
}
// Shape rasterization depends only on params, not on a tile's pixels.
let shape = match params {
FilterParams::ShapeBlur { radius, shape } => {
@@ -1197,6 +1211,7 @@ fn apply_tiled_with_impl(
}
};
let Some(mut data) = (match (&shape, &radial) {
_ if gallery.is_some() => gallery.as_ref().map(|g| g.run(src, *t, &ctx)),
(Some(shape), _) => shape.checked_run(src, *t, &ctx),
(None, Some(plan)) => match plan.filter(src, *t, &ctx, ctl) {
Some(data) => Some(data),
+20 -14
View File
@@ -35,7 +35,9 @@ pub struct RefineParams {
pub feather: f32,
/// 0–100.
pub contrast: f32,
/// −100–100. 100% moves the edge by `max(radius, 4)` pixels. Values between whole
/// −100–100. 100% moves the edge by its soft width: `max(radius, feather, 2 × smooth σ)`,
/// at least 1 px, so a hard edge moves a pixel (Photoshop's Shift Edge moves soft borders;
/// a band of several pixels around hard line art fills it in solid). Values between whole
/// pixels blend the two neighbouring shifts, so the slider does not jump.
pub shift_edge: f32,
}
@@ -60,9 +62,11 @@ 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.
/// Signed shift in pixels, fractional. 100% is the edge's soft width (at least 1 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);
let soft = self.radius.max(self.feather).max(2.0 * self.smooth_sigma()).max(1.0);
let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * soft;
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
@@ -765,22 +769,24 @@ mod tests {
assert!(f[5 * w + 29] > 0.5 && f[5 * w + 29] < 0.8 && f[5 * w + 31] > 0.1);
let c = refine_buffer(None, &f, w, h, &RefineParams { contrast: 100.0, ..Default::default() });
assert!(c[5 * w + 29] == 1.0 && c[5 * w + 31] == 0.0);
// A hard edge (no radius, feather or smooth) moves 1 px at 100%, so 50% is half a
// pixel. The pixel just outside fades in instead of snapping on.
let grown = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 100.0, ..Default::default() });
assert_eq!(grown[5 * w + 33], 1.0);
assert_eq!(grown[5 * w + 34], 0.0);
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_eq!(grown[5 * w + 30], 1.0);
assert_eq!(grown[5 * w + 31], 0.0);
let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -100.0, ..Default::default() });
assert_eq!(shrunk[5 * w + 28], 1.0);
assert_eq!(shrunk[5 * w + 29], 0.0);
let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 50.0, ..Default::default() });
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() });
let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() });
assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]);
// A softer edge moves farther: 100% is the feather, here 4 px.
let wide = refine_buffer(None, &m, w, h, &RefineParams { feather: 4.0, contrast: 100.0, shift_edge: 100.0, ..Default::default() });
assert_eq!(wide[5 * w + 33], 1.0);
assert_eq!(wide[5 * w + 34], 0.0);
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);
}
+2 -2
View File
@@ -286,7 +286,7 @@ fn sort_along(b: &mut Vec<([f32; 3], u32)>, axis: usize) {
}
/// Median cut over `pts` (feature vectors + weights) into at most `n` boxes; returns box means.
fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
pub(crate) fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
if pts.is_empty() || n == 0 {
return Vec::new();
}
@@ -334,7 +334,7 @@ fn median_cut(pts: &[([f32; 3], u32)], n: usize) -> Vec<[f32; 3]> {
}
/// Lloyd refinement of `centers` over weighted points.
fn kmeans(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) {
pub(crate) fn kmeans(pts: &[([f32; 3], u32)], centers: &mut [[f32; 3]], iters: usize) {
if centers.is_empty() {
// No room left (the forced colours fill the palette): nothing to refine.
return;
+191
View File
@@ -0,0 +1,191 @@
//! Separable blurs described as data, so another device (the GPU compositor's layer operators,
//! #5772) runs exactly the blur the CPU runs: the kernel weights or box widths come from here,
//! and [`crate::blur`] builds its own passes from the same description, so the two can't drift.
//!
//! [`OperatorRegion`] is the region rule that goes with it: which source pixels an output area
//! reads, with the edge pixels repeated at the extent as the CPU filters do.
use photocraft_geom::Rect;
use crate::{FilterParams, blur};
/// A blur made of passes along each axis, horizontal first, as the CPU runs it.
#[derive(Clone, Debug, PartialEq)]
pub enum Separable {
/// One pass of this normalised kernel (odd length, centred) along each axis.
Kernel(Vec<f32>),
/// Running-mean box passes of these odd widths, in turn, along each axis.
Boxes(Vec<usize>),
}
impl Separable {
/// The blur `params` runs on the CPU, or `None` for a filter that isn't one (only Gaussian
/// Blur so far). Small radii use the exact kernel, larger ones three box passes.
pub fn of(params: &FilterParams) -> Option<Self> {
match *params {
FilterParams::GaussianBlur { radius } => Some(match blur::box_widths(params) {
Some(boxes) => Separable::Boxes(boxes),
None => Separable::Kernel(blur::gaussian_blur_kernel(radius)),
}),
_ => None,
}
}
/// How far outside an output pixel the blur reads, along each axis.
pub fn reach(&self) -> i32 {
let reach = match self {
Separable::Kernel(k) => k.len() / 2,
Separable::Boxes(boxes) => boxes.iter().fold(0usize, |sum, w| sum.saturating_add(w / 2)),
};
i32::try_from(reach).unwrap_or(i32::MAX)
}
}
/// The area a neighbourhood filter on a layer repeats its edge pixels at: the canvas, or past it
/// where the layer has pixels off the canvas, as Photoshop does.
pub fn edge_extent(canvas: Rect, content: Rect) -> Rect {
canvas.union(&content)
}
/// Which source pixels an output area of a blur reads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OperatorRegion {
/// How far the blur reads outside an output pixel ([`Separable::reach`]).
pub reach: i32,
/// Where the source's edge pixels repeat ([`edge_extent`]): nothing outside it is read.
pub extent: Rect,
}
impl OperatorRegion {
/// The source pixels output `out` reads: `out` grown by the reach, inside the extent. Pixels
/// it needs beyond the extent repeat the edge of this area, so they need no source of their
/// own. Empty when `out` is empty or doesn't come within reach of the extent.
pub fn input(&self, out: Rect) -> Rect {
if out.is_empty() {
return Rect::EMPTY;
}
out.inflate(self.reach.max(0)).intersect(&self.extent)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Halo;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::{Interrupt, Surface};
const RADII: [f32; 11] = [0.01, 0.05, 0.1, 0.3, 0.8, 2.0, 4.0, 4.5, 12.0, 40.0, 250.0];
/// The CPU blur's own choice: the exact kernel up to radius 4, boxes beyond; and its halo
/// (how far the CPU grows an output area) covers the reach.
#[test]
fn the_description_is_the_cpus_blur() {
for radius in RADII {
let params = FilterParams::GaussianBlur { radius };
let blur = Separable::of(&params).unwrap();
match &blur {
Separable::Kernel(k) => {
assert!(radius <= 4.0, "radius {radius}");
assert_eq!(k, &blur::gaussian_blur_kernel(radius));
assert_eq!(k.len() % 2, 1);
assert!((k.iter().sum::<f32>() - 1.0).abs() < 1e-5);
}
Separable::Boxes(b) => {
assert!(radius > 4.0, "radius {radius}");
assert_eq!(Some(b.clone()), blur::box_widths(&params));
assert!(b.iter().all(|w| w % 2 == 1));
}
}
let Halo::Radius(halo) = params.halo() else { panic!("a Gaussian has a radius halo") };
assert!(blur.reach() <= halo, "radius {radius}: reach {} past the halo {halo}", blur.reach());
}
assert_eq!(Separable::of(&FilterParams::BoxBlur { radius: 9.0 }), None, "not a Gaussian (yet)");
assert_eq!(Separable::Boxes(vec![usize::MAX, 3]).reach(), i32::MAX, "saturates");
}
/// One opaque pixel in a transparent layer, blurred on the CPU, spreads no further than the
/// reach (beyond it the box passes' running sums leave only float residue, far below a
/// 16-bit level), and the exact kernel spreads exactly that far.
#[test]
fn an_impulse_spreads_no_further_than_the_reach() {
for radius in RADII {
let params = FilterParams::GaussianBlur { radius };
let reach = Separable::of(&params).unwrap().reach();
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
let size = 2 * reach + 41;
let canvas = Rect::new(0, 0, size, size);
let mut s = Surface::new(fmt);
let dot = Rect::new(size / 2, size / 2, size / 2 + 1, size / 2 + 1);
s.fill_rect(dot, &[1.0, 1.0, 1.0, 1.0]);
let out = crate::apply_in_with(&s, &params, canvas, canvas, None, canvas, &Interrupt::NONE).unwrap();
let mut px = Vec::new();
out.read_region_into(canvas, &mut px);
let touched = px
.as_chunks::<4>()
.0
.iter()
.enumerate()
// The box passes' running sums leave float residue (~1e-8) after the impulse.
.filter(|(_, p)| p[3] > 1e-6)
.fold(Rect::EMPTY, |r, (i, _)| {
let (x, y) = ((i as i32) % size, (i as i32) / size);
r.union(&Rect::new(x, y, x + 1, y + 1))
});
// Nothing past the reach; the exact kernel reaches all of it. (A single pixel's box
// tails are smaller than the running sums' residue, so they aren't checked for that.)
assert!(dot.inflate(reach).contains_rect(&touched), "radius {radius}: {touched:?} past the reach {reach}");
if let Some(Separable::Kernel(k)) = Separable::of(&params) {
// Calibrated subpixel kernels have tails below the visibility threshold.
let center = k.len() / 2;
let visible = k.iter().enumerate().filter(|(_, w)| **w * k[center] > 1e-6).map(|(i, _)| i.abs_diff(center)).max().unwrap_or(0);
assert_eq!(touched, dot.inflate(visible as i32), "radius {radius}");
}
}
}
/// Pixels outside [`OperatorRegion::input`] don't change an output area's result, and the
/// edge repeats at the extent: a layer's area next to the extent's edge blurs the same
/// whatever lies beyond it.
#[test]
fn output_depends_only_on_the_input_region() {
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
let extent = Rect::new(0, 0, 300, 200);
let noise = |seed: u32| {
let mut s = Surface::new(fmt);
for y in 0..200 {
for x in 0..300 {
let h = (x as u32).wrapping_mul(73_856_093) ^ (y as u32).wrapping_mul(19_349_663) ^ seed;
let v = (h % 1000) as f32 / 999.0;
s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[v, 1.0 - v, (h % 7) as f32 / 6.0, (h % 5) as f32 / 4.0]);
}
}
s
};
let a = noise(1);
for radius in [0.8, 4.0, 12.0] {
let params = FilterParams::GaussianBlur { radius };
let region = OperatorRegion { reach: Separable::of(&params).unwrap().reach(), extent };
for out in [Rect::new(100, 80, 140, 120), Rect::new(0, 0, 30, 25), Rect::new(270, 170, 300, 200)] {
let input = region.input(out);
assert!(extent.contains_rect(&input) && input.contains_rect(&out));
// Everything outside the input region replaced by other pixels.
let mut b = noise(2);
let mut inside = Vec::new();
a.read_region_into(input, &mut inside);
b.write_region(input, &inside);
let blur = |s: &Surface| {
let r = crate::apply_in_with(s, &params, out, extent, None, extent, &Interrupt::NONE).unwrap();
let mut px = Vec::new();
r.read_region_into(out, &mut px);
px
};
assert_eq!(blur(&a), blur(&b), "radius {radius}, {out:?}");
}
}
assert_eq!(OperatorRegion { reach: 5, extent }.input(Rect::EMPTY), Rect::EMPTY);
assert_eq!(OperatorRegion { reach: 5, extent }.input(Rect::new(400, 400, 410, 410)), Rect::EMPTY, "out of reach of the extent");
assert_eq!(OperatorRegion { reach: -3, extent }.input(Rect::new(10, 10, 20, 20)), Rect::new(10, 10, 20, 20));
assert_eq!(edge_extent(extent, Rect::new(-10, 50, 20, 60)), Rect::new(-10, 0, 300, 200));
}
}
+4 -1
View File
@@ -86,6 +86,9 @@ impl Headless {
match method {
"engine.execute" => {
let id = str_of(&p, "command").ok_or_else(|| bad("engine.execute needs `command`"))?;
if METHODS.contains(&id) && photocraft_engine::commands::find(id).is_none() {
return Err(bad(photocraft_engine::command_hints::protocol_method_as_command(id)));
}
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(true);
self.command_start(id, p.get("params").cloned().unwrap_or(Value::Null), wait)
}
@@ -186,7 +189,7 @@ impl Headless {
"doc.close" => self.close(index_of(&p)?),
"batch" => self.batch(&p),
"methods" => Ok(json!(METHODS)),
other => Err(bad(format!("unknown method `{other}` (try `methods`)"))),
other => Err(bad(format!("{} (try `methods`)", photocraft_engine::command_hints::unknown_method(other, METHODS)))),
}
}
+19 -1
View File
@@ -268,7 +268,8 @@ fn doc_save_rejects_a_bad_quality_instead_of_ignoring_or_clamping_it() {
}
h.handle("doc.save", json!({"path": path.to_string_lossy(), "quality": 85})).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(bytes.get(12..16), Some(&b"VP8 "[..]), "a WebP saved with a quality is lossy");
// The lossy chunk; an extended WebP (one carrying a colour profile) puts `VP8X` first.
assert!(bytes.windows(4).any(|c| c == b"VP8 ") && !bytes.windows(4).any(|c| c == b"VP8L"), "a WebP saved with a quality is lossy");
std::fs::remove_dir_all(dir).unwrap();
}
@@ -817,6 +818,23 @@ fn methods_lists_the_job_methods_and_every_listed_name_is_served() {
assert!(h.handle("jobs.nope", Value::Null).unwrap_err().to_string().contains("unknown method"));
}
/// #3674: a guessed name gets a pointer to the right one, in both directions.
#[test]
fn unknown_names_suggest_the_right_method_or_command() {
let mut h = Headless::new();
let err = |h: &mut Headless, m: &str, p: Value| h.handle(m, p).unwrap_err().to_string();
let e = err(&mut h, "engine.execute", json!({"command": "engine.commands"}));
assert!(e.contains("did you mean `command.list`") && e.contains("protocol method"), "{e}");
let e = err(&mut h, "engine.execute", json!({"command": "doc.inspect"}));
assert!(e.contains("`doc.inspect` is a protocol method"), "{e}");
let e = err(&mut h, "engine.execute", json!({"command": "edit.undoo"}));
assert!(e.contains("did you mean `edit.undo`"), "{e}");
let e = err(&mut h, "doc.inspct", Value::Null);
assert!(e.contains("did you mean `doc.inspect`") && e.contains("(try `methods`)"), "{e}");
let e = err(&mut h, "edit.undo", Value::Null);
assert!(e.contains("engine.execute {\"command\": \"edit.undo\"}"), "{e}");
}
#[test]
fn batch_response_budget_stops_later_steps_even_when_stop_on_error_is_false() {
let mut h = Headless::new();
+3 -1
View File
@@ -342,7 +342,9 @@ pub struct UiSetParams {
/// cropOverlayOrientation (0..3), cropShield (an object patching show_cropped_area, enabled,
/// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100),
/// auto_adjust, classic_mode and auto_center_preview), cropFill (background or contentAware,
/// the Crop tool's Fill), shapeStroke (an object patching width, align, cap, join, miterLimit,
/// the Crop tool's Fill), penMode (path or shape, the Pen tools' mode), penPathOp (newLayer,
/// combine, subtract, intersect or exclude: how the next Pen path joins the selected shape
/// layer or path), shapeStroke (an object patching width, align, cap, join, miterLimit,
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
+54 -24
View File
@@ -1,7 +1,7 @@
//! Capability-based filesystem policy for untrusted automation paths.
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
use std::path::{Component, Components, Path, PathBuf, Prefix};
use std::sync::Arc;
use cap_std::ambient_authority;
@@ -16,16 +16,18 @@ const DENIED: &str = "automation filesystem access is not granted";
#[derive(Clone)]
struct RootCapability {
dir: Arc<Dir>,
/// The root as an absolute path (trusted launch configuration), to recognise absolute request
/// The canonical root path (trusted launch configuration), to recognise absolute request
/// paths that name a file beneath it.
path: PathBuf,
/// The trusted launch spelling also names the same held capability, even through a link.
configured_path: PathBuf,
}
/// Separate directory capabilities for automation reads and writes.
///
/// Root paths are trusted launch-time configuration. Request paths are always
/// untrusted, forward-slash relative paths resolved by `cap-std` beneath the
/// held directory handle, or absolute paths whose text lies beneath the root
/// held directory handle, or absolute paths beneath the canonical or configured root spelling
/// (translated to relative ones, see [`request_path`]). Parent traversal,
/// alternate separators, Windows prefixes and empty components are rejected
/// before I/O.
@@ -117,7 +119,10 @@ impl AuthorizedWorkspace {
// Best effort, and only when there is a file being replaced: a save still
// succeeds for a document whose Finder tag could not come along.
if let Ok(old) = root.dir.open(&relative) {
let old = old.into_std();
let _ = photocraft_format::atomic::meta::copy_xattrs(old.as_fd(), file.as_fd());
// The creation time too (#9388), through the same handles.
let _ = photocraft_format::atomic::meta::copy_created_file(&old, &file);
}
}
// Falls back to a plain fsync on shares that refuse a full flush (#1336).
@@ -190,35 +195,60 @@ fn open_root(path: Option<&Path>, authority: &str) -> Result<Option<RootCapabili
if path.as_os_str().is_empty() {
return Err(AutomationError::BadRequest(format!("automation {authority} root is empty")));
}
let dir = Dir::open_ambient_dir(path, ambient_authority())
.map_err(|e| AutomationError::Io(format!("cannot open automation {authority} root `{}`: {e}", path.display())))?;
// Textual (no link resolution), like the request paths it is compared with.
// Windows absolute() removes parent components. Keep an absolute launch spelling intact:
// only that trusted prefix is exempt from request traversal checks, never its suffix.
let configured_path = if path.is_absolute() {
path.to_path_buf()
} else {
std::path::absolute(path).map_err(|e| AutomationError::Io(format!("cannot resolve automation {authority} root `{}`: {e}", path.display())))?
};
// Resolve trusted configuration once, before opening that exact directory as a capability.
let absolute =
std::path::absolute(path).map_err(|e| AutomationError::Io(format!("cannot resolve automation {authority} root `{}`: {e}", path.display())))?;
Ok(Some(RootCapability { dir: Arc::new(dir), path: absolute }))
std::fs::canonicalize(path).map_err(|e| AutomationError::Io(format!("cannot resolve automation {authority} root `{}`: {e}", path.display())))?;
let dir = Dir::open_ambient_dir(&absolute, ambient_authority())
.map_err(|e| AutomationError::Io(format!("cannot open automation {authority} root `{}`: {e}", path.display())))?;
Ok(Some(RootCapability { dir: Arc::new(dir), path: absolute, configured_path }))
}
/// A request path as the relative path the root's capability resolves. An absolute path is
/// accepted only when its text names a file beneath `root`: the root's components are
/// stripped (the drive letter compared without case) and the rest must pass
/// [`relative_path`], so `..`, prefixes and device names are refused as before. Nothing is
/// resolved on the filesystem; the held directory handle still does all the opening, so
/// links cannot escape the root.
/// Both root spellings refer to the capability opened at startup. Do not resolve an untrusted
/// absolute request using ambient filesystem access: strip a known root first and let cap-std
/// resolve the remaining name. New targets and nested export directories need not exist yet.
fn request_path(raw: &str, root: Option<&RootCapability>) -> Result<PathBuf, AutomationError> {
let Some(root) = root.filter(|_| Path::new(raw).is_absolute()) else { return relative_path(raw) };
let mut request = Path::new(raw).components();
for want in root.path.components() {
let same = match (request.next(), want) {
(Some(Component::Prefix(a)), Component::Prefix(b)) => a.as_os_str().eq_ignore_ascii_case(b.as_os_str()),
(Some(got), want) => got == want,
(None, _) => false,
let path = Path::new(raw);
let Some(root) = root.filter(|_| path.is_absolute()) else { return relative_path(raw) };
// Prefer the trusted launch spelling: it can itself contain `..`. Only that
// exact granted prefix is exempt; traversal in the remaining request still fails below.
let request = path_beneath_root(path, &root.configured_path)
.or_else(|| path_beneath_root(path, &root.path))
.ok_or_else(|| path_error(raw, "absolute paths must be inside the automation root"))?;
let rest = request.map(|c| c.as_os_str().to_str().ok_or_else(|| path_error(raw, "path is not UTF-8"))).collect::<Result<Vec<_>, _>>()?;
relative_path(&rest.join("/"))
}
/// Strip a granted root prefix without resolving the client path with ambient authority.
/// Windows ASCII case variants name the same grant; the unstripped suffix keeps its case.
fn path_beneath_root<'a>(path: &'a Path, root: &Path) -> Option<Components<'a>> {
let mut request = path.components();
for want in root.components() {
let same = match (request.next()?, want) {
(Component::Prefix(a), Component::Prefix(b)) => match (a.kind(), b.kind()) {
// std::fs::canonicalize uses verbatim paths on Windows. Accept their ordinary
// drive/UNC spelling too, without treating a device prefix as a disk prefix.
(Prefix::Disk(a) | Prefix::VerbatimDisk(a), Prefix::Disk(b) | Prefix::VerbatimDisk(b)) => a.eq_ignore_ascii_case(&b),
(Prefix::UNC(a, b) | Prefix::VerbatimUNC(a, b), Prefix::UNC(c, d) | Prefix::VerbatimUNC(c, d)) => {
a.eq_ignore_ascii_case(c) && b.eq_ignore_ascii_case(d)
}
_ => a.as_os_str().eq_ignore_ascii_case(b.as_os_str()),
},
#[cfg(windows)]
(Component::Normal(a), Component::Normal(b)) => a.eq_ignore_ascii_case(b),
(got, want) => got == want,
};
if !same {
return Err(path_error(raw, "absolute paths must be inside the automation root"));
return None;
}
}
let rest: Vec<&str> = request.map(|c| c.as_os_str().to_str().unwrap_or("\u{FFFD}")).collect();
relative_path(&rest.join("/"))
Some(request)
}
fn relative_path(raw: &str) -> Result<PathBuf, AutomationError> {
+229
View File
@@ -162,6 +162,22 @@ fn write_refuses_a_fifo_target_without_blocking() {
assert!(temp_files(&inside).is_empty());
}
/// #9401 review: replacing a file through the automation path keeps its creation time, the
/// same as a save through `atomic_write` does on macOS (#9388).
#[cfg(target_os = "macos")]
#[test]
fn write_keeps_the_replaced_files_creation_time() {
use std::os::macos::fs::FileTimesExt;
let (inside, _, workspace) = roots("created");
let path = inside.join("doc.psd");
workspace.write("doc.psd", b"original").unwrap();
let born = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_000);
std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_times(std::fs::FileTimes::new().set_created(born)).unwrap();
workspace.write("doc.psd", b"replacement").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"replacement");
assert_eq!(std::fs::metadata(&path).unwrap().created().unwrap(), born, "the creation time survives the automation write");
}
/// #7095: replacing a file through the automation path keeps the old file's extended
/// attributes, the same as a save through `atomic_write` does.
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
@@ -536,6 +552,8 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
| "path.convertPoint"
| "path.addAnchor"
| "path.deleteAnchor"
| "path.mergeShapeComponents"
| "path.setOperation"
| "path.clippingPath.set"
| "path.rename"
| "path.stroke"
@@ -638,6 +656,217 @@ fn absolute_paths_beneath_the_root_are_translated_and_others_refused() {
assert_eq!(std::fs::read(inside.join("a.bin")).unwrap(), b"one");
}
/// Equivalent drive, UNC and ASCII-case root spellings need no ambient path resolution.
#[cfg(windows)]
#[test]
fn canonical_windows_prefixes_match_without_ambient_resolution() {
for (request, root) in [
(r"c:\root\new.bin", r"\\?\C:\root"),
(r"\\?\C:\root\new.bin", r"C:\root"),
(r"\\server\share\root\new.bin", r"\\?\UNC\SERVER\SHARE\root"),
(r"c:\ROOT\new.bin", r"\\?\C:\root"),
(r"\\?\C:\ROOT\new.bin", r"c:\root"),
(r"\\server\share\ROOT\new.bin", r"\\?\UNC\SERVER\SHARE\root"),
] {
let remaining = path_beneath_root(Path::new(request), Path::new(root)).unwrap();
assert_eq!(remaining.as_path(), Path::new("new.bin"));
}
for request in [r"C:\root-sibling\new.bin", r"D:\root\new.bin", r"\\.\C:\root\new.bin", r"\\other\share\root\new.bin"] {
assert!(path_beneath_root(Path::new(request), Path::new(r"\\?\C:\root")).is_none());
}
}
/// Case variants of the granted Windows prefix still use its held directory capability.
#[cfg(windows)]
#[test]
fn windows_root_case_variants_keep_io_inside_the_grant() {
let (inside, outside, workspace) = roots("root-case");
let configured = PathBuf::from(inside.to_string_lossy().to_ascii_uppercase());
let canonical = PathBuf::from(std::fs::canonicalize(&inside).unwrap().to_string_lossy().to_ascii_uppercase());
for spelling in [&configured, &canonical] {
let path = spelling.join("new.png");
workspace.write(&path.to_string_lossy(), b"inside").unwrap();
assert_eq!(workspace.read(&path.to_string_lossy()).unwrap(), b"inside");
assert!(workspace.write(&spelling.join("..").join("outside").join("blocked.bin").to_string_lossy(), b"no").is_err());
}
assert_eq!(std::fs::read(inside.join("new.png")).unwrap(), b"inside");
assert!(workspace.write(&configured.with_file_name("INSIDE-SIBLING").join("blocked.bin").to_string_lossy(), b"no").is_err());
assert!(!outside.join("blocked.bin").exists());
// Case normalization must not bypass the refusal to replace unrelated existing files.
std::fs::write(inside.join("notes.bin"), b"keep").unwrap();
assert!(workspace.write(&configured.join("notes.bin").to_string_lossy(), b"no").is_err());
assert_eq!(std::fs::read(inside.join("notes.bin")).unwrap(), b"keep");
}
/// Unix roots with different case must not become aliases of the granted prefix.
#[cfg(unix)]
#[test]
fn unix_root_prefixes_remain_case_sensitive() {
assert!(path_beneath_root(Path::new("/Root/new.bin"), Path::new("/root")).is_none());
}
#[test]
fn configured_parent_components_accept_resolved_absolute_paths() {
let (inside, outside, _) = roots("resolved-parent-root");
let configured = inside.join("..").join("inside");
let workspace = AuthorizedWorkspace::new(Some(&configured), Some(&configured)).unwrap();
let resolved = std::fs::canonicalize(&inside).unwrap();
workspace.write(&resolved.join("new.bin").to_string_lossy(), b"new").unwrap();
assert_eq!(workspace.read(&resolved.join("new.bin").to_string_lossy()).unwrap(), b"new");
workspace.write(&configured.join("launch-spelling.bin").to_string_lossy(), b"trusted prefix").unwrap();
assert_eq!(workspace.read(&configured.join("launch-spelling.bin").to_string_lossy()).unwrap(), b"trusted prefix");
assert_eq!(workspace.read(&resolved.join("launch-spelling.bin").to_string_lossy()).unwrap(), b"trusted prefix");
assert!(workspace.write(&configured.join("..").join("outside/escaped.bin").to_string_lossy(), b"no").is_err());
assert!(!outside.join("escaped.bin").exists());
assert!(workspace.write(&outside.join("blocked.bin").to_string_lossy(), b"no").is_err());
assert!(!outside.join("blocked.bin").exists());
}
#[test]
fn relative_configured_root_accepts_resolved_absolute_paths() {
let relative = PathBuf::from("target").join(format!("automation-relative-root-{}", std::process::id()));
std::fs::create_dir_all(&relative).unwrap();
let workspace = AuthorizedWorkspace::new(Some(&relative), Some(&relative)).unwrap();
let resolved = std::fs::canonicalize(&relative).unwrap();
workspace.write(&resolved.join("new.bin").to_string_lossy(), b"relative").unwrap();
assert_eq!(workspace.read(&resolved.join("new.bin").to_string_lossy()).unwrap(), b"relative");
std::fs::remove_file(resolved.join("new.bin")).unwrap();
drop(workspace);
std::fs::remove_dir(resolved).unwrap();
}
#[cfg(unix)]
#[test]
fn trusted_root_names_are_not_validated_as_relative_request_names() {
let (inside, outside, _) = roots("trusted-root-names");
let root = inside.join("aux");
std::fs::create_dir(&root).unwrap();
let alias = outside.join("CON");
link_directory(&root, &alias);
let workspace = AuthorizedWorkspace::new(Some(&alias), Some(&alias)).unwrap();
for (index, spelling) in [&root, &alias].into_iter().enumerate() {
let fresh = spelling.join(format!("new-{index}.bin"));
workspace.write(&fresh.to_string_lossy(), b"trusted").unwrap();
assert_eq!(workspace.read(&fresh.to_string_lossy()).unwrap(), b"trusted");
assert!(workspace.write(&fresh.to_string_lossy(), b"replacement").is_err(), "unknown existing files remain protected");
assert!(workspace.write(&spelling.join("NUL").to_string_lossy(), b"no").is_err());
}
assert!(!root.join("NUL").exists());
remove_directory_link(&alias);
}
// Windows junctions exercise the same root alias without requiring Developer Mode.
#[cfg(any(unix, windows))]
fn link_directory(target: &Path, link: &Path) {
#[cfg(unix)]
std::os::unix::fs::symlink(target, link).unwrap();
#[cfg(windows)]
assert!(std::process::Command::new("cmd.exe").args(["/D", "/C", "mklink", "/J"]).arg(link).arg(target).output().unwrap().status.success());
}
#[cfg(any(unix, windows))]
fn remove_directory_link(link: &Path) {
#[cfg(unix)]
std::fs::remove_file(link).unwrap();
#[cfg(windows)]
std::fs::remove_dir(link).unwrap();
}
#[cfg(any(unix, windows))]
#[test]
fn linked_root_accepts_both_spellings_and_new_nested_exports() {
let (inside, outside, _) = roots("linked-root");
let link = outside.join("root-link");
link_directory(&inside, &link);
let workspace = AuthorizedWorkspace::new(Some(&link), Some(&link)).unwrap();
let resolved = std::fs::canonicalize(&inside).unwrap();
for (i, spelling) in [resolved, link.clone()].iter().enumerate() {
let name = format!("new-{i}.bin");
workspace.write(&spelling.join(&name).to_string_lossy(), b"new").unwrap();
assert_eq!(workspace.read(&spelling.join(&name).to_string_lossy()).unwrap(), b"new");
assert_eq!(workspace.read(&name).unwrap(), b"new");
let mut export = workspace.export_sequence(&spelling.join(format!("frames-{i}")).join("nested").to_string_lossy()).unwrap();
export.write("frame.png", b"frame").unwrap();
export.commit();
assert_eq!(std::fs::read(inside.join(format!("frames-{i}/nested/frame.png"))).unwrap(), b"frame");
}
remove_directory_link(&link);
}
/// TEMP can name the same directory differently from canonicalize (8.3 on Windows).
/// A junction makes that mismatch deterministic even when 8.3 names are disabled.
#[cfg(windows)]
#[test]
fn root_alias_tests_work_with_an_aliased_windows_temp() {
let (inside, outside, _) = roots("aliased-temp");
let temp_alias = outside.join("temp-alias");
link_directory(&inside, &temp_alias);
let exe = std::env::current_exe().unwrap();
for test in [
"workspace::tests::ungranted_absolute_aliases_and_link_escapes_are_refused",
"workspace::tests::retargeting_root_alias_does_not_regrant_the_held_authority",
] {
// Set TEMP only in the child; changing the process environment races other tests.
let output = std::process::Command::new(&exe).args(["--exact", test, "--nocapture"]).env("TEMP", &temp_alias).env("TMP", &temp_alias).output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "{test}: {stdout}\n{stderr}");
assert!(stdout.contains("1 passed;"), "child must run the requested test: {stdout}");
}
remove_directory_link(&temp_alias);
}
#[cfg(any(unix, windows))]
#[test]
fn ungranted_absolute_aliases_and_link_escapes_are_refused() {
let (inside, outside, workspace) = roots("request-alias");
// TEMP may use an 8.3 name or a junction. A grant through `alias` below recognises
// that configured alias and the canonical target, not every other name for `inside`.
let resolved_inside = std::fs::canonicalize(&inside).unwrap();
let alias = outside.join("inside-alias");
link_directory(&inside, &alias);
workspace.write("new.bin", b"inside").unwrap();
assert!(workspace.read(&alias.join("new.bin").to_string_lossy()).is_err());
assert!(workspace.write(&alias.join("ungranted.bin").to_string_lossy(), b"no").is_err());
assert!(!inside.join("ungranted.bin").exists());
std::fs::write(outside.join("secret.bin"), b"outside").unwrap();
let escape = inside.join("escape");
link_directory(&outside, &escape);
assert!(workspace.read(&escape.join("secret.bin").to_string_lossy()).is_err());
assert!(workspace.write(&escape.join("blocked.bin").to_string_lossy(), b"no").is_err());
assert!(workspace.export_sequence(&escape.join("frames").join("nested").to_string_lossy()).is_err());
assert!(!outside.join("blocked.bin").exists());
assert!(!outside.join("frames").exists());
assert_eq!(std::fs::read(outside.join("secret.bin")).unwrap(), b"outside");
let read_only = AuthorizedWorkspace::new(Some(&alias), Some(&outside)).unwrap();
assert_eq!(read_only.read(&resolved_inside.join("new.bin").to_string_lossy()).unwrap(), b"inside");
assert!(read_only.write(&alias.join("denied.bin").to_string_lossy(), b"no").is_err());
assert!(!inside.join("denied.bin").exists());
remove_directory_link(&escape);
remove_directory_link(&alias);
}
#[cfg(any(unix, windows))]
#[test]
fn retargeting_root_alias_does_not_regrant_the_held_authority() {
let (inside, outside, _) = roots("retargeted-root");
let resolved_inside = std::fs::canonicalize(&inside).unwrap();
let alias = outside.join("root-alias");
link_directory(&inside, &alias);
let workspace = AuthorizedWorkspace::new(Some(&alias), Some(&alias)).unwrap();
remove_directory_link(&alias);
link_directory(&outside, &alias);
std::fs::write(outside.join("secret.bin"), b"secret").unwrap();
workspace.write("held.bin", b"held").unwrap();
assert_eq!(workspace.read(&resolved_inside.join("held.bin").to_string_lossy()).unwrap(), b"held");
assert!(workspace.read(&alias.join("secret.bin").to_string_lossy()).is_err());
workspace.write(&alias.join("held-alias.bin").to_string_lossy(), b"original grant").unwrap();
assert_eq!(std::fs::read(inside.join("held-alias.bin")).unwrap(), b"original grant");
assert!(!outside.join("held-alias.bin").exists());
assert_eq!(std::fs::read(outside.join("secret.bin")).unwrap(), b"secret");
remove_directory_link(&alias);
}
/// Each authority is checked against its own root, and none granted refuses absolute paths.
#[test]
fn absolute_paths_are_checked_against_the_authority_s_own_root() {
+4
View File
@@ -311,6 +311,10 @@ async fn errors_are_tool_errors_not_crashes() {
let r = call(&client, "command_run", json!({"id": "no.such.command"})).await;
assert_eq!(r.is_error, Some(true));
assert!(text(&r).contains("unknown command"));
// #3674: the error names the nearest registered id.
let r = call(&client, "command_run", json!({"id": "engine.commands"})).await;
assert_eq!(r.is_error, Some(true));
assert!(text(&r).contains("did you mean `command.list`"), "{}", text(&r));
let r = call(&client, "doc_inspect", json!({"index": 0})).await;
assert_eq!(r.is_error, Some(true));
let r = call(&client, "doc_open", json!({"path": "/definitely/missing.png"})).await;
+208 -16
View File
@@ -225,34 +225,89 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
err("no LUT_3D_SIZE in .cube file")
}
/// Lustre `.3dl`: an optional shaper line of input values, then integer RGB rows with **blue**
/// varying fastest; the output bit depth is inferred from the largest value, and is at least 12
/// bits in a file without a header.
/// Lustre `.3dl`: an optional `3DMESH` magic and `Mesh <inBits> <outBits>` line, an optional
/// shaper line of input values, then integer RGB rows with **blue** varying fastest. The
/// `Mesh` line declares the cube edge (2^inBits + 1) and the output code range (up to
/// 2^outBits − 1), and Photoshop honours both (#9073, #9078); without it the edge is the cube
/// root of the row count and the range is inferred from the largest value (#9121).
pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
let mut mesh: Option<(u32, u32)> = None;
let mut rows: Vec<[f64; 3]> = Vec::new();
// How many numbers the first numeric line carried: a shaper of exactly `n` values (n ≤ 3)
// is otherwise indistinguishable from a node row.
let mut first_len: Option<usize> = None;
// Any text line before the rows is a header: a headerless table peaking at 255 or 1023 is
// on the 12-bit scale, as Photoshop 27.11 reads it (#9121).
let mut header = false;
for line in text.lines() {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() || line.starts_with(|c: char| c.is_ascii_alphabetic()) {
header |= !line.is_empty();
if line.is_empty() {
continue;
}
if line.starts_with(|c: char| c.is_ascii_alphabetic()) {
header = true;
// The only header keyword that carries data. A malformed or out-of-range `Mesh`
// line is ignored, as every text line always was.
let mut it = line.split_whitespace();
if it.next().is_some_and(|k| k.eq_ignore_ascii_case("Mesh")) {
// A declaration is exactly two integer fields after the keyword. Anything else
// (a non-numeric field, too few or too many) is not a declaration: `mesh` stays
// unset so headerless inference applies, rather than recovering numbers from
// the wrong positions (#9078).
let fields: Vec<&str> = it.collect();
if let [a, b] = fields.as_slice()
&& let (Ok(n), Ok(bits)) = (a.parse::<u32>(), b.parse::<u32>())
&& (1..=7).contains(&n)
&& (1..=16).contains(&bits)
{
mesh = Some((n, bits));
}
}
continue;
}
let v: Vec<f64> = line.split_whitespace().filter_map(|s| s.parse().ok()).collect();
if v.is_empty() {
// The `3DMESH` magic carries no numbers, but it is a header all the same: a table
// behind it reads a 1023 peak on the 10-bit scale (#9121).
header = true;
continue;
}
first_len.get_or_insert(v.len());
if v.len() == 3 {
rows.push([v[0], v[1], v[2]]);
}
// Lines with more than three numbers are the input shaper; it is assumed linear.
}
let n = (rows.len() as f64).cbrt().round() as usize;
if n < 2 || n * n * n != rows.len() {
return err(format!("{} rows is not a cube", rows.len()));
}
let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b));
// Photoshop 27.11 reads a headerless table peaking at 255 or 1023 on the 12-bit scale (#9121).
let depths: &[f64] = if header { &[1023.0, 4095.0, 16383.0, 65535.0] } else { &[4095.0, 16383.0, 65535.0] };
let scale = if max <= 1.0 { 1.0 } else { depths.iter().copied().find(|&s| max <= s).unwrap_or(max) };
let (n, nodes, scale) = match mesh {
Some((in_bits, out_bits)) => {
let n = 2usize.pow(in_bits) + 1;
let expected = n.checked_pow(3).ok_or_else(|| LutError(format!("unsupported 3dl cube size {n}")))?;
// One row more than the declared cube, and the first row has exactly `n` values:
// that row is the shaper, not a 28th node of a 3³ cube (#9078).
let nodes = if rows.len() == expected + 1 && first_len == Some(n) { &rows[1..] } else { &rows[..] };
if nodes.len() != expected {
return err(format!("expected {expected} rows for the declared {n}³ cube, found {}", nodes.len()));
}
// Under a valid declaration the node codes are integers in the declared range, so
// the declaration wins for every in-range value — including a small max, where 1 is
// 1/declared of full output, not full output itself (#9073). Only a node beyond the
// declared range is a wrong declaration, and the old inference keeps such a file
// loading as before.
let max = nodes.iter().flatten().fold(0.0f64, |a, &b| a.max(b));
let declared = 2f64.powi(i32::try_from(out_bits).unwrap_or(16)) - 1.0;
(n, nodes, if max > declared { inferred_3dl_scale(max, header) } else { declared })
}
None => {
let n = (rows.len() as f64).cbrt().round() as usize;
if n < 2 || n * n * n != rows.len() {
return err(format!("{} rows is not a cube", rows.len()));
}
let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b));
(n, &rows[..], inferred_3dl_scale(max, header))
}
};
let mut data = vec![0.0f32; n * n * n * 3];
for (i, row) in rows.iter().enumerate() {
for (i, row) in nodes.iter().enumerate() {
let (r, g, b) = (i / (n * n), (i / n) % n, i % n);
let at = ((b * n + g) * n + r) * 3;
for k in 0..3 {
@@ -262,6 +317,18 @@ pub fn parse_3dl(text: &str) -> Result<LutFile, LutError> {
LutFile { title: String::new(), size: n, data, domain_min: [0.0; 3], domain_max: [1.0; 3] }.check()
}
/// The code range a `.3dl` node table without a declared output depth was written with: the
/// smallest common integer depth that covers the largest value; a table of 0..1 floats passes
/// through. A headerless table has no 10-bit reading — 255 and 1023 are on the 12-bit scale, as
/// Photoshop 27.11 reads them (#9121).
fn inferred_3dl_scale(max: f64, header: bool) -> f64 {
if max <= 1.0 {
return 1.0;
}
let depths: &[f64] = if header { &[1023.0, 4095.0, 16383.0, 65535.0] } else { &[4095.0, 16383.0, 65535.0] };
depths.iter().copied().find(|&s| max <= s).unwrap_or(max)
}
/// SpeedGrade `.look`: `<size>` and a hex `<data>` string of little-endian float32 RGB triplets
/// (red fastest).
pub fn parse_look(text: &str) -> Result<LutFile, LutError> {
@@ -515,8 +582,9 @@ DOMAIN_MAX 0.5 1 1
let white = parse_3dl(&table(peak)).unwrap().data[21..24].to_vec();
assert_eq!(white, [peak as f32 / 4095.0; 3], "peak {peak}");
}
// A header keeps the inferred 10-bit depth.
let white = parse_3dl(&format!("3DMESH\nMesh 1 10\n{}", table(1023))).unwrap().data[21..24].to_vec();
// A header keeps the inferred 10-bit depth. The bare `3DMESH` magic is a header without a
// cube declaration; `Mesh 1 10` would declare a 3³ cube and refuse this 2³ table (#9078).
let white = parse_3dl(&format!("3DMESH\n{}", table(1023))).unwrap().data[21..24].to_vec();
assert_eq!(white, [1.0; 3]);
}
@@ -535,6 +603,130 @@ DOMAIN_MAX 0.5 1 1
assert_eq!(l.data, LutFile::identity(2).data);
}
/// A `size`³ inverse-identity `.3dl` (blue fastest): node (r,g,b) outputs `max - max·v/(size-1)`
/// per channel, with a uniform shaper of `size` codes up to 1023.
fn mesh_3dl(n_bits: u32, out_bits: u32, size: usize, max: f64, shaper: bool) -> String {
let mut text = format!("3DMESH\nMesh {n_bits} {out_bits}\n");
if shaper {
for i in 0..size {
if i > 0 {
text.push(' ');
}
text.push_str(&format!("{}", (1023.0 * i as f64 / (size - 1) as f64).round()));
}
text.push('\n');
}
for r in 0..size {
for g in 0..size {
for b in 0..size {
let v = [r, g, b].map(|c| (max - max * c as f64 / (size - 1) as f64).round());
text.push_str(&format!("{} {} {}\n", v[0], v[1], v[2]));
}
}
}
text
}
#[test]
fn three_dl_mesh_three_point_shaper_is_not_a_node() {
// #9078: Photoshop loads a 3³ cube whose shaper line has three values; counting it as a
// 28th node rejected the whole file.
let l = parse_3dl(&mesh_3dl(1, 12, 3, 4095.0, true)).unwrap();
assert_eq!(l.size, 3);
assert_eq!(l.data.len(), 3 * 3 * 3 * 3);
// The inverse identity: node (0,0,0) is full, node (2,2,2) black.
assert!((l.data[0] - 1.0).abs() < 1e-6 && (l.data[1] - 1.0).abs() < 1e-6 && (l.data[2] - 1.0).abs() < 1e-6);
let last = &l.data[l.data.len() - 3..];
assert_eq!(last, [0.0, 0.0, 0.0]);
// The same 27 nodes without the shaper row still load (the node-only control).
assert_eq!(parse_3dl(&mesh_3dl(1, 12, 3, 4095.0, false)).unwrap().data, l.data);
}
#[test]
fn three_dl_mesh_declares_the_output_depth() {
// #9073: identical node codes divide by the declared range, not by the largest value.
let mut text_10 = String::new();
let mut text_12 = String::new();
for (n_bits, out_bits, dst) in [(2, 10, &mut text_10), (2, 12, &mut text_12)] {
// A 5³ identity scaled to 10-bit codes (max 1023).
*dst = format!("3DMESH\nMesh {n_bits} {out_bits}\n0 256 512 768 1023\n");
for r in 0..5 {
for g in 0..5 {
for b in 0..5 {
dst.push_str(&format!("{} {} {}\n", r * 255, g * 255, b * 255));
}
}
}
}
let white_10 = parse_3dl(&text_10).unwrap();
let white_12 = parse_3dl(&text_12).unwrap();
assert_eq!(white_10.size, 5);
let last_10 = &white_10.data[white_10.data.len() - 3..];
let last_12 = &white_12.data[white_12.data.len() - 3..];
assert!((last_10[0] - 1020.0 / 1023.0).abs() < 1e-6, "{last_10:?}");
// 1020/4095 ≈ 0.249: white renders grey, as Photoshop measures it.
assert!((last_12[0] - 1020.0 / 4095.0).abs() < 1e-6, "{last_12:?}");
}
#[test]
fn three_dl_mesh_header_mismatch_is_rejected() {
// A Mesh header that disagrees with the row count is refused, not silently re-inferred.
assert!(parse_3dl("3DMESH\nMesh 1 12\n0 0 0\n0 0 1\n").is_err());
// A node beyond the declared range is a wrong declaration: the old inference from the
// largest value keeps the file loading as it did before the header was honoured.
let mut text = String::from("3DMESH\nMesh 1 10\n");
for r in 0..3 {
for g in 0..3 {
for b in 0..3 {
text.push_str(&format!("{} {} {}\n", r * 2047, g * 2047, b * 2047));
}
}
}
let l = parse_3dl(&text).unwrap();
let last = &l.data[l.data.len() - 3..];
assert!((last[0] - 4094.0 / 4095.0).abs() < 1e-6, "{last:?}");
}
#[test]
fn three_dl_malformed_mesh_header_falls_back_to_inference() {
// A `Mesh` line that is not exactly two integer fields is not a declaration: it is
// ignored and the table is inferred as if headerless, rather than recovering numbers
// from the wrong positions (#9078). A 2³ cube would otherwise be read as a 3³ cube.
for header in ["Mesh invalid 1 12", "Mesh 1 12 13", "Mesh 1 x", "Mesh 1"] {
let mut text = format!("3DMESH\n{header}\n");
for r in 0..2 {
for g in 0..2 {
for b in 0..2 {
text.push_str(&format!("{} {} {}\n", r * 1023, g * 1023, b * 1023));
}
}
}
let l = parse_3dl(&text).unwrap();
assert_eq!(l.size, 2, "{header}");
assert_eq!(l.data, LutFile::identity(2).data, "{header}");
}
}
#[test]
fn three_dl_mesh_uses_declared_range_for_small_integer_codes() {
// #9073: under a valid `Mesh` declaration the node codes are integers in the declared
// range, so a max value of 1 is 1/4095 of full output — not full output itself.
let mut text = String::from("3DMESH\nMesh 1 12\n");
for r in 0..3 {
for g in 0..3 {
for b in 0..3 {
let v = u8::from(r == 0 && g == 0 && b == 0);
text.push_str(&format!("{v} {v} {v}\n"));
}
}
}
let l = parse_3dl(&text).unwrap();
assert_eq!(l.size, 3);
// blue fastest: node (0,0,0) is the first entry, code 1 → 1/4095.
assert!((l.data[0] - 1.0 / 4095.0).abs() < 1e-6, "{:?}", &l.data[0..3]);
assert!(l.data[3..].iter().all(|&v| v == 0.0), "{:?}", l.data);
}
#[test]
fn look_hex_floats() {
let id = LutFile::identity(2);
+6
View File
@@ -33,6 +33,8 @@ zune-core = "0.5"
jpeg-encoder = "0.6"
# 12-bit and arithmetic-coded JPEG, which zune-jpeg does not decode.
craft-jpeg.workspace = true
# Pure-Rust modular JPEG XL lossless encoder.
zune-jpegxl = { version = "=0.5.2", default-features = false, features = ["std"] }
photocraft-heif = { path = "../heif", optional = true }
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
@@ -46,5 +48,9 @@ proptest.workspace = true
# Oracle decoders for cross-checking in tests only.
image = { workspace = true, features = ["png", "jpeg", "tiff"] }
# Independent JPEG XL decoder oracle; not linked into the application.
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
jxl-oxide = { version = "=0.12.6", default-features = false, features = ["rayon"] }
[lints]
workspace = true
+12
View File
@@ -46,6 +46,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
| FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in |
| JPEG XL (.jxl) | **no** (see #2868) | yes | U8, U16 | Gray, RGB, RGBA (GrayA expands to RGBA) | yes | no (bare codestream) | no | no | no | no | no | `zune-jpegxl` |
| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in |
| Cineon | yes | yes | U16 (10-bit code values read and written exactly through U16) | Gray, RGB | no | no | no | no | no | no | no | built in |
| DICOM | yes | **no** | U8, U16 | Gray, RGB | no | no | no | no | no | no | n/a | built in |
@@ -73,6 +74,17 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
## Documented asymmetries and limitations
* **JPEG XL export.** Writes lossless 8/16-bit bare codestreams with `zune-jpegxl`.
Native layouts are Gray, RGB and RGBA. GrayA is expanded to RGBA at the
original depth. For 16-bit RGBA (including expanded GrayA), we correct
zune-jpegxl 0.5.2's extra-channel header, which otherwise describes the
16-bit alpha samples as 8-bit. This preserves alpha precision; the independent
jxl-oxide decoder test checks the actual gray and alpha samples. The expansion
is not a native GrayA layout. No ICC, EXIF, XMP or DPI container boxes are written; the
document pipeline converts tagged images to sRGB and reports metadata loss.
Reading remains in the separate #2868 PR. The `ASYMMETRIC_EXCEPTIONS`
entry stays until import is merged.
* **DDS.** The top mip level of a 2D texture: uncompressed pixels described by channel masks
(8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5),
with the legacy header or the DX10 one. Uncompressed row padding follows the header's
+1
View File
@@ -11,6 +11,7 @@ pub(crate) mod gif_palette;
pub(crate) mod heif;
pub(crate) mod iff;
pub(crate) mod jpeg;
pub(crate) mod jxl;
pub(crate) mod pcx;
pub(crate) mod png;
pub(crate) mod pnm;
+159
View File
@@ -0,0 +1,159 @@
//! Pure-Rust lossless JPEG XL (Modular) writer.
//!
//! Currently writes a bare JPEG XL codestream. The encoder accepts 8- or 16-bit
//! gray, RGB and RGBA samples, but does not write ICC/EXIF/XMP boxes.
//! Gray+alpha sources are expanded losslessly to RGBA by the export plan.
//! The flat export pipeline converts tagged RGB documents to sRGB first.
use std::panic::{AssertUnwindSafe, catch_unwind};
use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions as ZuneOptions};
use zune_jpegxl::JxlSimpleEncoder;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::format::Format;
use crate::image::{ChannelLayout, Image, SampleType};
const F: Format = Format::Jxl;
// zune-jpegxl 0.5.2 emits ExtraChannelInfo(all_default=true) for every alpha
// channel. In JPEG XL that flag means *8-bit* alpha, even when the modular
// payload was encoded from 16-bit samples. Replace only that metadata bundle,
// leaving the entire encoded frame and its TOC untouched. The replacement is
// exactly 16 bits longer, so subsequent byte-aligned data stays aligned.
//
// This deliberately verifies the pinned encoder's header layout rather than
// trying to parse or rewrite arbitrary JPEG XL codestreams. A different header
// must fail closed, not produce a file that silently corrupts transparency.
fn correct_16_bit_alpha_header(bytes: &[u8], width: u32, height: u32) -> Result<Vec<u8>, CodecError> {
fn read(bytes: &[u8], pos: &mut usize, count: usize) -> Option<u32> {
let mut value = 0u32;
for bit in 0..count {
let offset = *pos;
let byte = *bytes.get(offset / 8)?;
value |= u32::from((byte >> (offset % 8)) & 1) << bit;
*pos += 1;
}
Some(value)
}
fn read_dimension(bytes: &[u8], pos: &mut usize) -> Option<u32> {
let bits = match read(bytes, pos, 2)? {
0 => 9,
1 => 13,
2 => 18,
_ => 30,
};
read(bytes, pos, bits)?.checked_add(1)
}
fn write_bits(out: &mut Vec<u8>, pos: &mut usize, count: usize, value: u32) {
for bit in 0..count {
if (*pos).is_multiple_of(8) {
out.push(0);
}
out[*pos / 8] |= (((value >> bit) & 1) as u8) << (*pos % 8);
*pos += 1;
}
}
let mut pos = 0;
let header_matches = read(bytes, &mut pos, 16) == Some(0x0aff)
&& read(bytes, &mut pos, 1) == Some(0)
&& read_dimension(bytes, &mut pos) == Some(height)
&& read(bytes, &mut pos, 3) == Some(0)
&& read_dimension(bytes, &mut pos) == Some(width)
&& read(bytes, &mut pos, 1) == Some(0) // metadata not all-default
&& read(bytes, &mut pos, 1) == Some(0) // no extra fields
&& read(bytes, &mut pos, 1) == Some(0) // integer samples
&& read(bytes, &mut pos, 2) == Some(3) // custom bit depth
&& read(bytes, &mut pos, 6) == Some(15) // 16 bits
&& read(bytes, &mut pos, 1) == Some(0) // uses >14 bits
&& read(bytes, &mut pos, 2) == Some(1); // one extra channel
let alpha_flag_pos = pos;
let header_matches = header_matches
&& read(bytes, &mut pos, 1) == Some(1) // incorrect default 8-bit alpha
&& read(bytes, &mut pos, 1) == Some(0) // not XYB
&& read(bytes, &mut pos, 1) == Some(1) // default sRGB encoding (RGBA)
&& read(bytes, &mut pos, 2) == Some(0) // no extensions
&& read(bytes, &mut pos, 1) == Some(1); // default transform
let frame_offset = pos.div_ceil(8);
let header_matches = header_matches && frame_offset <= bytes.len() && (pos..frame_offset * 8).all(|bit| bytes[bit / 8] & (1 << (bit % 8)) == 0);
if !header_matches {
return Err(CodecError::encode(F, "unexpected zune-jpegxl header for 16-bit alpha"));
}
let mut fixed = Vec::with_capacity(bytes.len() + 2);
let mut cursor = 0;
for bit in 0..alpha_flag_pos {
write_bits(&mut fixed, &mut cursor, 1, u32::from((bytes[bit / 8] >> (bit % 8)) & 1));
}
// JPEG XL ExtraChannelInfo, non-default, straight 16-bit alpha:
// type=kAlpha (Enum=0), bit_depth=uint16, dim_shift=0, name_len=0,
// alpha_associated=false. Adds exactly 16 bits versus all_default=true.
write_bits(&mut fixed, &mut cursor, 1, 0);
write_bits(&mut fixed, &mut cursor, 2, 0);
write_bits(&mut fixed, &mut cursor, 1, 0);
write_bits(&mut fixed, &mut cursor, 2, 3);
write_bits(&mut fixed, &mut cursor, 6, 15);
write_bits(&mut fixed, &mut cursor, 2, 0);
write_bits(&mut fixed, &mut cursor, 2, 0);
write_bits(&mut fixed, &mut cursor, 1, 0);
for bit in alpha_flag_pos + 1..frame_offset * 8 {
write_bits(&mut fixed, &mut cursor, 1, u32::from((bytes[bit / 8] >> (bit % 8)) & 1));
}
if cursor != (frame_offset + 2) * 8 {
return Err(CodecError::encode(F, "JPEG XL alpha header alignment mismatch"));
}
fixed.extend_from_slice(&bytes[frame_offset..]);
Ok(fixed)
}
pub(crate) fn encode(image: &Image, plan: Plan) -> Result<Vec<u8>, CodecError> {
let image = image.converted(plan.layout, plan.sample);
let space = match image.layout() {
ChannelLayout::Gray => ColorSpace::Luma,
ChannelLayout::GrayA => ColorSpace::LumaA,
ChannelLayout::Rgb => ColorSpace::RGB,
ChannelLayout::Rgba => ColorSpace::RGBA,
_ => return Err(CodecError::unsupported(F, "JPEG XL export supports grayscale and RGB, with optional alpha")),
};
let depth = match image.sample_type() {
SampleType::U8 => BitDepth::Eight,
SampleType::U16 => BitDepth::Sixteen,
_ => return Err(CodecError::unsupported(F, "JPEG XL lossless export supports 8 or 16-bit integer pixels")),
};
let (width, height) = image.dimensions();
let samples = (width as usize)
.checked_mul(height as usize)
.and_then(|n| n.checked_mul(image.layout().channels()))
.and_then(|n| n.checked_mul(image.sample_type().bytes()))
.ok_or_else(|| CodecError::encode(F, "image dimensions overflow the buffer size"))?;
if image.data().len() != samples {
return Err(CodecError::InvalidImage("incorrect JPEG XL pixel buffer length".into()));
}
// A panic in an external image encoder must not terminate the host application.
catch_unwind(AssertUnwindSafe(|| {
let options = ZuneOptions::new(width as usize, height as usize, space, depth);
let mut output = Vec::new();
JxlSimpleEncoder::new(image.data(), options).encode(&mut output).map_err(|e| CodecError::encode(F, e.to_string()))?;
if depth == BitDepth::Sixteen && image.layout() == ChannelLayout::Rgba { correct_16_bit_alpha_header(&output, width, height) } else { Ok(output) }
}))
.unwrap_or_else(|_| Err(CodecError::encode(F, "the JPEG XL encoder failed")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alpha_header_correction_rejects_unexpected_or_truncated_headers() {
for bytes in [&[][..], &[0xff, 0x0a][..], &[0; 32][..]] {
assert!(correct_16_bit_alpha_header(bytes, 2, 2).is_err());
}
}
}
+14 -1
View File
@@ -34,6 +34,8 @@ pub enum Format {
Fits,
/// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames.
Dpx,
/// JPEG XL: lossless export-only until the separate importer lands.
Jxl,
/// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from).
Cineon,
/// DICOM single-frame images (uncompressed): read-only.
@@ -85,6 +87,7 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.",
),
(Format::Jxl, "Lossless JPEG XL export uses the pure-Rust zune-jpegxl encoder; JPEG XL reading is tracked separately and is not yet available."),
(
Format::Heif,
"HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \
@@ -104,9 +107,10 @@ const TIFF_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::
impl Format {
/// Every format known to the crate (enabled or not).
pub const ALL: [Format; 22] = [
pub const ALL: [Format; 23] = [
Format::Png,
Format::Jpeg,
Format::Jxl,
Format::Tiff,
Format::WebP,
Format::Gif,
@@ -160,6 +164,7 @@ impl Format {
Format::Heif => "HEIF",
Format::Fits => "FITS",
Format::Dpx => "DPX",
Format::Jxl => "JPEG XL",
Format::Cineon => "Cineon",
Format::Dicom => "DICOM",
Format::Iff => "Amiga IFF",
@@ -189,6 +194,7 @@ impl Format {
Format::Heif => &["heic", "heif", "hif"],
Format::Fits => &["fits", "fit", "fts"],
Format::Dpx => &["dpx"],
Format::Jxl => &["jxl"],
Format::Cineon => &["cin"],
Format::Dicom => &["dcm", "dicom", "dc3"],
Format::Iff => &["iff", "lbm", "ilbm"],
@@ -216,6 +222,7 @@ impl Format {
Format::Heif => "image/heif",
Format::Fits => "image/fits",
Format::Dpx => "image/x-dpx",
Format::Jxl => "image/jxl",
Format::Cineon => "image/x-cin",
Format::Dicom => "application/dicom",
Format::Iff => "image/x-iff",
@@ -276,6 +283,8 @@ pub fn caps(format: Format) -> FormatCaps {
Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base },
// `text`: the DPX header fields only (see `fidelity_warnings_with`).
Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base },
// GrayA is widened to RGBA at the same depth; the 16-bit alpha header is corrected.
Format::Jxl => FormatCaps { read: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], ..base },
Format::Cineon => FormatCaps { depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
Format::Dicom => FormatCaps { read: true, write: false, depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
Format::Iff => FormatCaps { depths: &[S::U8], layouts: &[L::Rgb, L::Rgba], alpha: true, ..base },
@@ -292,6 +301,10 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
if b.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Some(Format::Jpeg);
}
// JPEG XL bare codestream and ISO/IEC 18181-2 container signature box.
if b.starts_with(&[0xFF, 0x0A]) || b.starts_with(b"\0\0\0\x0cJXL \r\n\x87\n") {
return Some(Format::Jxl);
}
if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") || b.starts_with(b"II+\0") || b.starts_with(b"MM\0+") {
return Some(Format::Tiff);
}
+4 -2
View File
@@ -48,7 +48,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::{dds, dicom, dpx, exr, fits, heif, iff, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm};
use crate::codecs::{dds, dicom, dpx, exr, fits, heif, iff, jpeg, jxl, pcx, png, pnm, tiff, via_image, webp, xpm};
/// Detect the format and decode with default [`Limits`].
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
@@ -69,7 +69,7 @@ pub fn decode_as(format: Format, bytes: &[u8]) -> Result<Image, CodecError> {
/// Decode as a specific format.
pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Result<Image, CodecError> {
// HEIF without the `heif` feature: its decoder stub says what is missing from this build.
if !caps(format).read && format != Format::Heif {
if !caps(format).read && !matches!(format, Format::Heif | Format::Jxl) {
return Err(CodecError::unsupported(format, "decoding is not available for this format"));
}
let l = &opts.limits;
@@ -89,6 +89,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
Format::Iff => iff::decode(bytes, l),
Format::OpenExr => exr::decode(bytes, l),
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
Format::Jxl => Err(CodecError::unsupported(format, "JPEG XL reading is not yet available; export is supported")),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
}?;
// Without a resolution of the format's own (PNG pHYs, TIFF tags, JPEG's metadata), the
@@ -307,6 +308,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
Format::Iff => iff::encode(image, plan),
Format::OpenExr => exr::encode(image, plan, opts),
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
Format::Jxl => jxl::encode(image, plan),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Lossless JPEG XL export and honest export-only capability reporting.
use photocraft_codecs::{
ASYMMETRIC_EXCEPTIONS, ChannelLayout, CodecError, EncodeOptions, Format, Image, SampleType, caps, decode, detect, encode, from_extension,
};
#[test]
fn jpeg_xl_is_export_only_and_detects_both_signatures() {
let c = caps(Format::Jxl);
assert!(c.write && !c.read);
assert_eq!(c.depths, &[SampleType::U8, SampleType::U16]);
assert_eq!(c.layouts, &[ChannelLayout::Gray, ChannelLayout::Rgb, ChannelLayout::Rgba]);
assert!(c.alpha && !c.icc && !c.exif && !c.xmp && !c.dpi && !c.lossy);
assert_eq!(Format::Jxl.mime_type(), "image/jxl");
assert_eq!(from_extension("image.JXL"), Some(Format::Jxl));
assert_eq!(detect(&[0xff, 0x0a, 0, 0]), Some(Format::Jxl));
assert_eq!(detect(b"\0\0\0\x0cJXL \r\n\x87\n"), Some(Format::Jxl));
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(format, reason)| *format == Format::Jxl && !reason.is_empty()));
let err = decode(&[0xff, 0x0a]);
assert!(matches!(err, Err(CodecError::Unsupported { format: Format::Jxl, .. })), "{err:?}");
}
#[test]
fn jpeg_xl_encoding_is_deterministic_and_converts_float_to_integer() {
let input =
Image::from_u8(3, 2, ChannelLayout::Rgba, vec![0, 255, 17, 127, 20, 40, 80, 255, 255, 0, 2, 0, 10, 20, 30, 64, 0, 0, 0, 1, 90, 80, 70, 128]).unwrap();
let opts = EncodeOptions::default();
let one = encode(&input, Format::Jxl, &opts).unwrap();
let two = encode(&input, Format::Jxl, &opts).unwrap();
assert_eq!(one, two);
assert_eq!(detect(&one), Some(Format::Jxl));
let float = Image::from_f32(2, 2, ChannelLayout::Rgb, &[0.0, 0.5, 1.0, 0.25, 0.5, 0.75, 0.75, 0.5, 0.25, 1.0, 0.0, 0.5]).unwrap();
let bytes = encode(&float, Format::Jxl, &opts).unwrap();
assert_eq!(detect(&bytes), Some(Format::Jxl));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn jpeg_xl_decodes_losslessly_with_independent_decoder() {
use jxl_oxide::JxlImage;
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] {
let channels = layout.channels();
for sample in [SampleType::U8, SampleType::U16] {
let (w, h) = (7, 5);
let count = w as usize * h as usize * channels;
let expected = match sample {
SampleType::U8 => (0..count).map(|i| (((i * 37 + 19) % 256) as u16) * 257).collect::<Vec<_>>(),
SampleType::U16 => (0..count).map(|i| ((i * 9973 + 27) % 65536) as u16).collect::<Vec<_>>(),
_ => unreachable!(),
};
let input = match sample {
SampleType::U8 => {
let data = expected.iter().map(|v| (v / 257) as u8).collect();
Image::from_u8(w, h, layout, data).unwrap()
}
SampleType::U16 => Image::from_u16(w, h, layout, &expected).unwrap(),
_ => unreachable!(),
};
// GrayA is losslessly widened to RGBA. Keep the oracle comparison
// against original gray and alpha samples, not only against a converted image.
let (output_channels, expected_output) = if layout == ChannelLayout::GrayA {
let expanded: Vec<u16> = expected.as_chunks::<2>().0.iter().flat_map(|[gray, alpha]| [*gray, *gray, *gray, *alpha]).collect();
(4, expanded)
} else {
(channels, expected.clone())
};
let encoded = encode(&input, Format::Jxl, &EncodeOptions::default()).unwrap();
let decoder = JxlImage::builder().read(&encoded[..]).unwrap();
assert_eq!((decoder.width(), decoder.height()), (w, h));
let render = decoder.render_frame(0).unwrap();
let mut stream = render.stream();
assert_eq!(stream.channels() as usize, output_channels, "{layout:?} {sample:?}");
let mut output = vec![0u16; expected_output.len()];
assert_eq!(stream.write_to_buffer(&mut output), expected_output.len());
assert_eq!(output, expected_output, "{layout:?} {sample:?}");
}
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn jpeg_xl_preserves_sixteen_bit_alpha_across_dimension_header_sizes() {
use jxl_oxide::JxlImage;
// Exercise each size-header coding tier, and non-opaque alpha extremes.
for width in [2u32, 513, 8193] {
let height = 2u32;
let mut samples = Vec::with_capacity(width as usize * height as usize * 4);
for i in 0..(width as usize * height as usize) {
samples.extend_from_slice(&[(i as u16).wrapping_mul(127), 65535, (i as u16).wrapping_mul(9973), [0u16, 1, 32768, 65535][i % 4]]);
}
let input = Image::from_u16(width, height, ChannelLayout::Rgba, &samples).unwrap();
let encoded = encode(&input, Format::Jxl, &EncodeOptions::default()).unwrap();
let decoder = JxlImage::builder().read(&encoded[..]).unwrap();
let render = decoder.render_frame(0).unwrap();
let mut stream = render.stream();
assert_eq!(stream.channels(), 4, "width={width}");
let mut output = vec![0u16; samples.len()];
assert_eq!(stream.write_to_buffer(&mut output), samples.len());
assert_eq!(output, samples, "width={width}");
}
}
#[test]
fn jpeg_xl_rejects_one_pixel_images_without_panicking() {
// zune-jpegxl currently requires at least two pixels on each axis.
let image = Image::from_u8(1, 1, ChannelLayout::Rgb, vec![42, 100, 255]).unwrap();
assert!(matches!(encode(&image, Format::Jxl, &EncodeOptions::default()), Err(CodecError::Encode { format: Format::Jxl, .. })));
}
+51 -21
View File
@@ -72,8 +72,10 @@ pub(crate) fn branchless_floor(x: f32) -> f32 {
}
/// Blend an adjustment layer's result (`adjusted`) back into the backdrop it was made from, each
/// pixel by its coverage `ks` (opacity × mask) in blend mode `mode`.
fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterator<Item = f32>, mode: BlendMode) {
/// pixel by its coverage `ks` (opacity × mask) in blend mode `mode` at layer Fill `fill`: below
/// 100% the special eight ([`psblend::fill_is_special`]) blend a result pulled toward the mode's
/// neutral colour, as a pixel layer does (#9501); for every other mode Fill is in `ks`.
fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterator<Item = f32>, mode: BlendMode, fill: f32) {
for ((b, a), k) in backdrop.iter_mut().zip(adjusted).zip(ks) {
if k <= 0.0 {
continue;
@@ -81,8 +83,11 @@ fn blend_back(backdrop: &mut [[f32; 4]], adjusted: &[[f32; 4]], ks: impl Iterato
let s = [a[0], a[1], a[2]];
// Normal is the source as it is (`blend_rgb` gives exactly that), and most adjustment
// layers are Normal: no per-pixel mode dispatch for them.
let blended = if mode == BlendMode::Normal { s } else { blend::blend_rgb(mode, [b[0], b[1], b[2]], s) };
*b = [b[0] + (blended[0] - b[0]) * k, b[1] + (blended[1] - b[1]) * k, b[2] + (blended[2] - b[2]) * k, b[3]];
let blended = if mode == BlendMode::Normal { s } else { blend::blend_rgb_fill(mode, [b[0], b[1], b[2]], s, fill) };
// `b·(1−k) + s·k`, not `b + (s − b)·k`: with a huge 32-bit backdrop the difference loses a
// small result to f32 rounding, so full coverage gave 0 instead of a lookup's 0.5 (#9483).
let j = 1.0 - k;
*b = [b[0] * j + blended[0] * k, b[1] * j + blended[1] * k, b[2] * j + blended[2] * k, b[3]];
}
}
@@ -147,9 +152,9 @@ fn blend_if_weight(layer: &Layer, mode: photocraft_color::ColorMode, this: Optio
let Some(p) = px else { continue };
let v = |c: usize| blend_if_level(p[c]);
match mode {
// Gray is the colour channels' luma (Rec. 601 weights), then R, G, B.
// Gray, then R, G, B.
M::Rgb => {
let gray = 0.299 * v(0) + 0.587 * v(1) + 0.114 * v(2);
let gray = blend_if_gray(v(0), v(1), v(2));
k *= bi.get(0)[side].weight(gray);
for c in 0..3 {
k *= bi.get(c + 1)[side].weight(v(c));
@@ -166,15 +171,28 @@ fn blend_if_weight(layer: &Layer, mode: photocraft_color::ColorMode, this: Optio
k
}
/// A channel value on Blend If's 0..=255 scale. Compositing can leave an 8- or 16-bit level a few
/// millionths off (a level-20 backdrop composited at partial alpha reads 19.999998), which would
/// put it on the wrong side of a hard point at that level: a value that close to a level is that
/// level. Values of those depths that aren't on a level are at least 1/257 away from one. The GPU
/// compositor (`compose.wgsl` `blend_if_weight`) snaps alike.
/// A channel value on Blend If's 0..=255 scale. Photoshop compares Blend If at its 16-bit
/// precision (0..=32768): a cutoff at level `c` falls on the step `round(c · 32768 / 255)`, and a
/// value on that step is exactly level `c`, inside both a black point and a white point there
/// (#9521: RGB16 32895 is step 16448, level 128's, so it passes a hard black point at 128; read as
/// 127.996 it didn't). Every 8-bit level is on its own step, which also absorbs compositing's few
/// millionths of drift (a level-20 backdrop at partial alpha reads 19.999998). The GPU compositor
/// (`compose.wgsl` `blend_if_weight`) snaps alike.
fn blend_if_level(x: f32) -> f32 {
let v = x.clamp(0.0, 1.0) * 255.0;
let r = v.round();
if (v - r).abs() < 1e-4 { r } else { v }
let x = x.clamp(0.0, 1.0);
let v = x * 255.0;
let level = v.round();
if (x * 32768.0).round() == (level * 32768.0 / 255.0).round() { level } else { v }
}
/// Blend If's Gray from channel levels: Photoshop's (30 R + 59 G + 11 B) / 100, rounded half down
/// on 8-bit levels (#9522). Measured in Photoshop 2025: pure red passes a black point at 20 from
/// 66 (19.8) but not 65 (19.5), pure blue at 28 from 251 (27.61) but not 250 (27.5); (123, 126,
/// 151) is 128 (127.85) and (120, 129, 149) is 128 (128.5), where Rec. 601 luma gave 127.95 and
/// 128.59. Levels between whole ones (deeper documents) keep the unrounded weighting.
fn blend_if_gray(r: f32, g: f32, b: f32) -> f32 {
let sum = 30.0 * r + 59.0 * g + 11.0 * b;
if [r, g, b].iter().all(|c| c.fract() == 0.0) { ((sum + 49.0) / 100.0).floor() } else { sum / 100.0 }
}
/// Blend If groups inside one another whose own colour is still rendered separately. Each extra
@@ -191,13 +209,18 @@ thread_local! {
/// Mixing premultiplied colour by `k` equals compositing the layer at `k` × its alpha, since
/// every blend mode's source-over result is linear in the source alpha.
fn apply_blend_if(layer: &Layer, before: &Buffer, out: &mut Buffer, cx: &Ctx) {
// "This Layer" is the layer's own colour; adjustment layers (no content of their own) are
// judged by their result. So are groups nested deeper than `MAX_BLEND_IF_GROUP_DEPTH` Blend
// Ifs: a group's own colour re-composites its children, which doubles the work at every
// level of Blend If groups inside one another.
// "This Layer" is the layer's own colour. An adjustment layer (no content of its own) is
// judged by its result at full strength, before Opacity and its mask mix it in, as Photoshop
// does (#9542). Groups nested deeper than `MAX_BLEND_IF_GROUP_DEPTH` Blend Ifs are judged by
// the composite: a group's own colour re-composites its children, which doubles the work at
// every level of Blend If groups inside one another.
let is_group = matches!(layer.content, LayerContent::Group(_));
let nested = BLEND_IF_GROUPS.with(std::cell::Cell::get);
let own = if matches!(layer.content, LayerContent::Adjustment(_)) || (is_group && nested >= MAX_BLEND_IF_GROUP_DEPTH) {
let own = if let LayerContent::Adjustment(adj) = &layer.content {
let mut result = before.clone();
cx.apply_adjustment(adj, &mut result);
Some(result)
} else if is_group && nested >= MAX_BLEND_IF_GROUP_DEPTH {
None
} else {
BLEND_IF_GROUPS.with(|d| d.set(nested + u32::from(is_group)));
@@ -334,6 +357,8 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
// is the base of a clipping group and holds an adjustment layer (psd-tools
// passthrough_clipping_mask_adjustment: an Exposure inside leaves the layers beneath alone),
// while such a group's blend modes still reach the backdrop (passthrough_clipping_mask_blendmode).
// A group with Blend If renders isolated too: an adjustment inside it no longer changes the
// layers beneath (#9554).
let clip_base_adjusts = clipped.iter().any(|c| c.visible)
&& matches!(&layer.content, LayerContent::Group(g) if g.children.iter().any(|c| c.visible && matches!(c.content, LayerContent::Adjustment(_))));
if let LayerContent::Group(g) = &layer.content
@@ -341,6 +366,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
&& layer.fill_opacity >= 1.0
&& !effects::has_effects(layer)
&& !clip_base_adjusts
&& !blend_if_active(layer, cx.mode)
{
let needs_mix = opacity < 1.0 || layer.mask.is_some() || layer.vector_mask.is_some();
let has_clipped = clipped.iter().any(|c| c.visible);
@@ -420,9 +446,13 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
}
}
} else {
// Fill below 100% in one of the special eight changes the blend itself and leaves
// coverage to Opacity, as for a pixel layer (psblend::composite_fill, #9501).
let (k, fill) =
if layer.fill_opacity < 1.0 && psblend::fill_is_special(layer.blend) { (layer.opacity, layer.fill_opacity) } else { (opacity, 1.0) };
match &mv {
Some(m) => blend_back(&mut backdrop.px, &adjusted.px, m.iter().map(|v| opacity * v), layer.blend),
None => blend_back(&mut backdrop.px, &adjusted.px, std::iter::repeat(opacity), layer.blend),
Some(m) => blend_back(&mut backdrop.px, &adjusted.px, m.iter().map(|v| k * v), layer.blend, fill),
None => blend_back(&mut backdrop.px, &adjusted.px, std::iter::repeat(k), layer.blend, fill),
}
}
recycle_buffer(adjusted);
+9 -2
View File
@@ -41,9 +41,9 @@ mod paint;
pub use bevel::{BEVEL_H_EPS, BevelGeom, BevelPaint, TextureCtx, bevel_chisel_h, bevel_geom, satin_distance};
#[cfg(test)]
use bevel::{bevel_maps, satin_map};
pub use blur::tent_kernel;
#[cfg(test)]
use blur::{blur, box_line, dilate, tent, tent_direct};
pub use blur::{softer_glow_tail_amplitude, softer_glow_tail_width, tent_kernel};
pub(crate) use composite::{VectorStroke, build_maps_prepared, composite_with_effects_prepared, map_count};
pub use composite::{build_maps, composite_with_effects, outline_share, premerges_layer, shadow_see_through, stroke_widths};
pub use distance::{FieldKind, STROKE_CHAMFER_MAX, bevel_distance_fields, distance_field, stroke_outside_exact};
@@ -89,7 +89,14 @@ pub fn margin(layer: &Layer) -> i32 {
for e in layer.effects.items.iter().filter(|e| e.enabled()) {
let r = match e {
Effect::DropShadow(s) | Effect::InnerShadow(s) => s.distance + s.size,
Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.size,
Effect::OuterGlow(g) => {
if g.technique == GlowTechnique::Softer {
g.size + softer_glow_tail_width(g.size)
} else {
g.size
}
}
Effect::InnerGlow(g) => g.size,
Effect::Stroke(s) => s.size,
Effect::Satin(s) => satin_distance(s) + s.size,
Effect::BevelEmboss(b) => b.size + b.soften,
+11
View File
@@ -210,3 +210,14 @@ fn tent_fast(m: &mut Map, w: f32) {
rows_par(&t, &mut b, mh, twice);
m.v = transpose_map(&b, mh, mw);
}
/// Raw coverage at the Size boundary, scaled through the glow's Range to a subtle one-percent tail.
pub fn softer_glow_tail_amplitude(range: f32) -> f32 {
let range = if range.is_finite() { range.clamp(0.01, 1.0) } else { 1.0 };
0.005 * range
}
/// Width of the added fading tail on a Softer outer glow, capped so large settings stay local.
pub fn softer_glow_tail_width(size: f32) -> f32 {
if size.is_finite() && size > 0.0 { (size * 0.1).clamp(1.0, 6.0) } else { 0.0 }
}
+26 -1
View File
@@ -1,4 +1,4 @@
use super::blur::{blur, dilate};
use super::blur::{blur, dilate, softer_glow_tail_amplitude, softer_glow_tail_width};
use super::distance::{dist_inside, dist_outside};
use super::{
BlendMode, Buffer, Contour, Effect, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Map, Placement, PreparedPatterns, Rect,
@@ -303,6 +303,9 @@ pub(super) fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -
let (r, bw) = spread_split(g.size, g.spread);
let mut m = dilate(&src, r);
blur(&mut m, bw);
if !inner {
add_softer_glow_tail(&mut m, &src, g.size, g.range);
}
m
}
};
@@ -402,3 +405,25 @@ pub(super) fn paint_glow(dst: &mut Buffer, m: &Map, g: &Glow, shape_bounds: Rect
let strength = Map { w: m.w, h: m.h, v: m.v.iter().map(|v| (v * gain).min(1.0)).collect() };
paint(dst, &strength, |i| prepared.sample(1.0 - m.v.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0)), g.common.blend, g.common.opacity);
}
fn add_softer_glow_tail(map: &mut Map, shape: &Map, size: f32, range: f32) {
let width = softer_glow_tail_width(size);
if width == 0.0 {
return;
}
let amp = softer_glow_tail_amplitude(range);
let distance = dist_outside(shape);
for (coverage, distance) in map.v.iter_mut().zip(distance) {
let t = (distance + 0.5 - (size - width)) / width;
let tail = if (0.0..=1.0).contains(&t) {
amp * t * t * (3.0 - 2.0 * t)
} else if (1.0..=2.0).contains(&t) {
let u = t - 1.0;
let remaining = 1.0 - u;
amp * remaining * remaining * (1.0 + 2.0 * u)
} else {
0.0
};
*coverage = coverage.max(tail);
}
}
+50
View File
@@ -858,6 +858,56 @@ fn glows_match_photoshop() {
}
}
#[test]
fn softer_outer_glow_keeps_a_fading_tail_past_its_size() {
let mut shape = Map::new(220, 220, 0.0);
for y in 0..shape.h {
for x in 20..100 {
shape.v[y * shape.w + x] = 1.0;
}
}
let glow = Glow {
common: photocraft_doc::FxCommon::new(BlendMode::Screen, 1.0),
paint: FxPaint::Color(photocraft_color::Color::WHITE),
technique: GlowTechnique::Softer,
spread: 0.0,
size: 60.0,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source: GlowSource::Edge,
};
let m = glow_map(&shape, &glow, false, (0, 0));
let row = shape.h / 2;
let at = |distance: usize| m.v[row * m.w + 100 + distance];
assert!(at(50) > 0.024, "the softer shoulder should stay visible: {}", at(50));
assert!(at(60) > 1.0 / 255.0, "the glow must retain a visible tail at its Size: {}", at(60));
assert!(at(62) > 0.5 / 255.0, "the glow must extend beyond its Size: {}", at(62));
assert!(at(66) < 1e-6, "the added tail should fade to zero: {}", at(66));
}
#[test]
fn softer_outer_glow_margin_includes_its_tail_support() {
let glow = Glow {
common: photocraft_doc::FxCommon::new(BlendMode::Screen, 1.0),
paint: FxPaint::Color(photocraft_color::Color::WHITE),
technique: GlowTechnique::Softer,
spread: 0.0,
size: 60.0,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source: GlowSource::Edge,
};
let mut layer = Layer::raster("glow", photocraft_color::PixelFormat::RGBA8);
layer.effects.items.push(Effect::OuterGlow(glow));
assert_eq!(margin(&layer), 68);
}
#[test]
fn offsets_follow_light() {
let (dx, dy) = offset(120.0, 10.0);
+30
View File
@@ -137,6 +137,36 @@ fn parallel_feathered_pixel_mask_returns_the_same_tiles() {
assert!(outputs.iter().all(|surface| Arc::ptr_eq(surface.tiles().next().unwrap().1, outputs[0].tiles().next().unwrap().1)));
}
/// A feather reaching the canvas edge sees the mask continue past it with its edge values, as
/// in Photoshop (#9475): a rectangle near the edge feathers the same on a taller canvas holding
/// the same mask (clamping each blur pass at the edge brightened it by up to 12 levels at
/// 32 px), and a mask white up to the edge stays white along it.
#[test]
fn feather_at_the_canvas_edge_continues_the_mask_past_it() {
let mut layer = Layer::raster("edge", PixelFormat::RGBA8);
let mut mask = photocraft_doc::LayerMask::hide_all();
mask.surface.fill_rect(Rect::new(40, 20, 120, 60), &[1.0]);
mask.feather = 32.0;
layer.mask = Some(mask);
let canvas = Rect::new(0, 0, 160, 80);
let tight = combined_mask(&layer, canvas).unwrap().read_region(canvas);
let tall = combined_mask(&layer, Rect::new(0, -200, 160, 280)).unwrap().read_region(canvas);
let worst = tight.iter().zip(&tall).map(|(a, b)| (a - b).abs()).fold(0.0_f32, f32::max);
assert!(worst < 1e-4, "the canvas edge changed the feather by {worst}");
let mut half = photocraft_doc::LayerMask::hide_all();
half.surface.fill_rect(Rect::new(0, 0, 80, 80), &[1.0]);
half.feather = 32.0;
layer.mask = Some(half);
let m = combined_mask(&layer, canvas).unwrap().read_region(canvas);
// Photoshop: every row the same, no darkening towards the top or bottom edge.
let middle = &m[40 * 160..41 * 160];
for (y, row) in m.as_chunks::<160>().0.iter().enumerate() {
assert!(row.iter().zip(middle).all(|(a, b)| (a - b).abs() < 1e-5), "row {y} differs from row 40");
}
assert!(middle[0] > 0.99, "the left edge stays white: {}", middle[0]);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn vector_mask_build_inside_a_small_rayon_pool_completes() {
+2 -1
View File
@@ -91,7 +91,8 @@ fn density_shows_the_fill_beyond_the_path() {
/// mask break differently: 127 and 128, both what Photoshop's "127/128" allows.)
#[test]
fn soft_shape_matches_an_equal_vector_mask() {
for (density, feather) in [(1.0, 10.0), (0.45, 0.0), (0.3, 6.5), (0.0, 0.0), (1.0, 0.0), (0.5, 3.0)] {
// 40 px reaches past the canvas edge: both feather it the same way there (#9475).
for (density, feather) in [(1.0, 10.0), (0.45, 0.0), (0.3, 6.5), (0.0, 0.0), (1.0, 0.0), (0.5, 3.0), (1.0, 40.0)] {
for depth in [SampleType::U8, SampleType::U16] {
let (s, f) = (shape_doc(depth, density, feather, None), fill_doc(depth, density, feather));
for y in [0, 45, 50, 100, 151, 199] {
+235 -6
View File
@@ -417,6 +417,59 @@ fn every_blend_mode_matches_reference_on_opaque_pixels() {
}
}
/// #9483: a Color Lookup's bounded output survives full coverage over a huge 32-bit pixel. The
/// blend back to the backdrop computed `b + (r - b) · k`, and in f32 the difference had already
/// lost the small result (1e8 and 0.5 gave 0).
#[test]
fn full_coverage_adjustment_keeps_its_result_over_huge_hdr_values() {
for v in [1.0e6_f32, 1.0e8, -1.0e6] {
let mut d = Document::new("hdr", Size::new(2, 2), ColorMode::Rgb, SampleType::F32);
let mut base = Layer::raster("b", PixelFormat::RGBA32F);
base.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 2), &[v, v, v, 1.0]);
let look = Adjustment::ColorLookup {
name: "half".into(),
lut: Some(std::sync::Arc::new(vec![0.5; 24])),
size: 2,
tetrahedral: false,
dither: false,
source: None,
};
d.layers = vec![base, Layer::new("look", LayerContent::Adjustment(look))];
let got = px(&d, 0, 0);
for c in &got[..3] {
assert!((c - 0.5).abs() < 1e-6, "{v}: {got:?}");
}
}
}
/// #9501: an adjustment layer at 40% Fill in Color Dodge or Linear Burn blends a result pulled
/// toward the mode's neutral colour, as a pixel layer does, instead of fading the blend by Fill.
/// Photoshop: a constant 0.5 look over (64, 128, 192) gives (80, 160, 240) and (13, 77, 141).
#[test]
fn adjustment_fill_changes_the_special_blend_modes() {
for (mode, want) in [(BlendMode::ColorDodge, [80, 160, 240]), (BlendMode::LinearBurn, [13, 77, 141])] {
let mut d = Document::with_background("fill", Size::new(1, 1), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut base = Layer::raster("b", PixelFormat::RGBA8);
base.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &[64.0 / 255.0, 128.0 / 255.0, 192.0 / 255.0, 1.0]);
let look = Adjustment::ColorLookup {
name: "half".into(),
lut: Some(std::sync::Arc::new(vec![0.5; 24])),
size: 2,
tetrahedral: false,
dither: false,
source: None,
};
let mut adj = Layer::new("look", LayerContent::Adjustment(look));
adj.blend = mode;
adj.fill_opacity = 0.4;
d.layers.push(base);
d.layers.push(adj);
let got = px(&d, 0, 0);
let got: Vec<i32> = got[..3].iter().map(|v| (v * 255.0).round() as i32).collect();
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1), "{mode:?}: {got:?} vs Photoshop {want:?}");
}
}
/// One opaque pixel: `top` in `mode` over `bottom`, in a document of `depth`.
fn blend_px(depth: SampleType, mode: BlendMode, bottom: [f32; 3], top: [f32; 3]) -> [f32; 4] {
let mut d = Document::new("b", Size::new(1, 1), ColorMode::Rgb, depth);
@@ -564,6 +617,35 @@ fn clipped_dissolve_matches_whole_render_at_offset_regions_and_depths() {
}
}
/// #4921: Dissolve coverage must survive clipping and use Opacity × Fill.
#[test]
fn clipped_dissolve_is_binary_and_preserves_the_base_shape() {
let mut d = doc_white(40, 12);
d.layers.push(solid_layer("base", Rect::new(0, 0, 20, 12), [0.0, 0.0, 1.0, 1.0]));
let mut clip = solid_layer("clip", d.bounds(), [1.0, 0.0, 0.0, 1.0]);
clip.clipped = true;
clip.blend = BlendMode::Dissolve;
clip.opacity = 0.6;
clip.fill_opacity = 0.5;
d.layers.push(clip);
let mut red = 0;
let mut blue = 0;
for y in 0..12 {
for x in 0..20 {
let p = px(&d, x, y);
if close4(p, [1.0, 0.0, 0.0, 1.0]) {
red += 1;
} else {
assert!(close4(p, [0.0, 0.0, 1.0, 1.0]), "Dissolve must not interpolate: {p:?} at ({x},{y})");
blue += 1;
}
}
assert!(close4(px(&d, 30, y), [1.0; 4]), "outside the base the clip is invisible");
}
assert!(red > 0 && blue > 0, "Dissolve must expose both source and base: {red}/{blue}");
assert!((red as f32 / (red + blue) as f32 - 0.3).abs() < 0.1, "coverage should follow 60% opacity × 50% fill: {red}/{blue}");
}
#[test]
fn hidden_base_hides_clipping_group() {
let mut d = doc_white(4, 4);
@@ -959,6 +1041,55 @@ fn dissolve_coverage_matches_opacity() {
assert_eq!(flatten(&d), b);
}
/// Invert at 50% Dissolve is black or white, not the gray of a Normal mix (#3050).
#[test]
fn dissolve_adjustment_is_a_per_pixel_choice() {
let mut d = doc_white(16, 16);
let mut inv = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
inv.blend = photocraft_color::BlendMode::Dissolve;
inv.opacity = 0.5;
d.layers.push(inv);
let b = flatten(&d);
let (black, white, other) = b.px.iter().fold((0, 0, 0), |(k, w, o), p| {
if close4(*p, [0.0, 0.0, 0.0, 1.0]) {
(k + 1, w, o)
} else if close4(*p, [1.0, 1.0, 1.0, 1.0]) {
(k, w + 1, o)
} else {
(k, w, o + 1)
}
});
assert_eq!(other, 0, "a partial mix would be gray");
assert!(black > 40 && white > 40, "black {black} white {white}");
}
/// A clipped black fill at 50% Dissolve stays black or white on an opaque base (#3052).
#[test]
fn clipped_dissolve_fill_is_a_per_pixel_choice() {
let mut d = doc_white(16, 16);
let mut l = solid_layer("k", Rect::new(0, 0, 16, 16), [0.0, 0.0, 0.0, 1.0]);
l.clipped = true;
l.blend = photocraft_color::BlendMode::Dissolve;
l.opacity = 0.5;
d.layers.push(l);
let b = flatten(&d);
assert!(b.px.iter().all(|p| close4(*p, [0.0, 0.0, 0.0, 1.0]) || close4(*p, [1.0; 4])));
}
/// A Color Overlay must not turn Dissolve coverage into a Normal mix (#3051).
#[test]
fn color_overlay_keeps_dissolve_coverage() {
use photocraft_doc::effects::{Effect, FxCommon};
let mut d = doc_white(16, 16);
let mut l = Layer::new("ink", LayerContent::Fill(Fill::Solid(photocraft_color::Color::BLACK)));
l.effects.items.push(Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: photocraft_color::Color::BLACK });
l.blend = photocraft_color::BlendMode::Dissolve;
l.opacity = 0.5;
d.layers.push(l);
let b = flatten(&d);
assert!(b.px.iter().all(|p| close4(*p, [0.0, 0.0, 0.0, 1.0]) || close4(*p, [1.0; 4])));
}
#[test]
fn render_is_tile_independent() {
// Rendering a sub-rect must equal the same region of a full render.
@@ -2479,6 +2610,71 @@ fn blend_if_hard_point_includes_its_own_level() {
}
}
/// #9521: Photoshop compares Blend If at its 16-bit precision (0..32768). A hard point at level
/// `c` sits on the step `round(c · 32768 / 255)`, inclusive for both black and white points:
/// measured in Photoshop 2025 at 1, 64, 128 and 200 (black first shown at steps 129, 8224, 16448,
/// 25700; white last shown at the same steps). RGB16 32895 is step 16448, so a white layer with
/// Underlying Blue black at 128 shows over it; 32894 (step 16447) hides it.
#[test]
fn blend_if_16_bit_cutoffs_follow_photoshops_steps() {
let shown = |v16: u16, black: u8, white: u8| {
let v = f32::from(v16) / 65535.0;
let mut d = Document::new("t", Size::new(1, 1), ColorMode::Rgb, SampleType::U16);
let layer16 = |name: &str, c: [f32; 4]| {
let mut l = Layer::raster(name, PixelFormat::RGBA16);
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &c);
l
};
d.layers.push(layer16("under", [v, v, v, 1.0]));
let mut l = layer16("top", [1.0, 1.0, 1.0, 1.0]);
l.blend_if.set(3, [FULL, range([black, black], [white, white])]);
d.layers.push(l);
px(&d, 0, 0)[0] > 0.999
};
// 16-bit file values for Photoshop's steps n: round(n · 65535 / 32768).
let at = |n: u32| ((n * 65535 + 16384) / 32768) as u16;
for (c, step) in [(1u8, 129u32), (64, 8224), (128, 16448), (200, 25700)] {
assert!(shown(at(step), c, 255), "black {c}: step {step} shows");
assert!(!shown(at(step - 1), c, 255), "black {c}: step {} hides", step - 1);
assert!(shown(at(step), 0, c), "white {c}: step {step} shows");
assert!(!shown(at(step + 1), 0, c), "white {c}: step {} hides", step + 1);
}
assert!(shown(32895, 128, 255) && !shown(32894, 128, 255), "the reporter's RGB16 boundary");
}
/// #9522: Blend If's Gray is Photoshop's (30 R + 59 G + 11 B) / 100 rounded half down, measured
/// in Photoshop 2025 with a white layer over one 8-bit backdrop colour and a hard Gray point on
/// Underlying Layer. Rec. 601 luma hid both of the reporter's colours.
#[test]
fn blend_if_gray_follows_photoshops_weights_and_rounding() {
let shown = |rgb: [u8; 3], black: u8, white: u8| {
let mut d = Document::new("t", Size::new(1, 1), ColorMode::Rgb, SampleType::U8);
let c = rgb.map(|v| f32::from(v) / 255.0);
d.layers.push(solid_layer("under", Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]));
let mut l = solid_layer("top", Rect::new(0, 0, 1, 1), [1.0, 1.0, 1.0, 1.0]);
l.blend_if.set(0, [FULL, range([black, black], [white, white])]);
d.layers.push(l);
px(&d, 0, 0)[0] > 0.999 && px(&d, 0, 0)[2] > 0.999
};
// (colour hidden by a black point at `cut`, the next colour shown), single channels.
for (cut, below, at) in [
(20, [65, 0, 0], [66, 0, 0]),
(25, [81, 0, 0], [82, 0, 0]),
(28, [91, 0, 0], [92, 0, 0]),
(20, [0, 33, 0], [0, 34, 0]),
(28, [0, 46, 0], [0, 47, 0]),
(20, [0, 0, 177], [0, 0, 178]),
(28, [0, 0, 250], [0, 0, 251]),
] {
assert!(!shown(below, cut, 255), "{below:?} hidden by black {cut}");
assert!(shown(at, cut, 255), "{at:?} shown by black {cut}");
}
// The reporter's mixed colours, and the white point's half-down rounding.
assert!(shown([123, 126, 151], 128, 255), "127.85 is 128");
assert!(shown([120, 129, 149], 0, 128), "128.5 is 128");
assert!(!shown([121, 129, 149], 0, 128), "128.8 is 129");
}
#[test]
fn blend_if_split_point_fades_like_opacity() {
// A split black point (Alt-drag) at 50/150 shows a value-100 layer at half strength, which
@@ -2537,6 +2733,25 @@ fn blend_if_on_adjustment_and_clipped_layers() {
assert!(close4(px(&d, 6, 1), [0.0, 1.0, 0.0, 1.0]));
}
/// #9554: a Pass Through group with Blend If renders isolated, as Photoshop does: an Invert inside
/// it leaves the layer beneath alone. Photoshop: (64, 128, 192) under such a group with Red ›
/// Underlying Layer white at 110 stays (64, 128, 192); without the Blend If it is inverted.
#[test]
fn pass_through_group_with_blend_if_is_isolated() {
for (gated, want) in [(true, [64, 128, 192]), (false, [191, 127, 63])] {
let mut d = Document::with_background("pt", Size::new(1, 1), ColorMode::Rgb, SampleType::U8, Color::WHITE);
d.layers.push(solid_layer("base", Rect::new(0, 0, 1, 1), [64.0 / 255.0, 128.0 / 255.0, 192.0 / 255.0, 1.0]));
let mut g = Layer::group("group", vec![Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert))]);
g.blend = BlendMode::PassThrough;
if gated {
g.blend_if.set(1, [FULL, range([0, 0], [110, 110])]);
}
d.layers.push(g);
let got: Vec<i32> = px(&d, 0, 0)[..3].iter().map(|v| (v * 255.0).round() as i32).collect();
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1), "Blend If {gated}: {got:?} vs Photoshop {want:?}");
}
}
#[test]
fn blend_if_modes() {
// Grayscale documents: the single channel's entry (or the gray entry) applies.
@@ -4187,12 +4402,8 @@ fn reused_render_matches_previous_allocating_stack() {
adjust::apply_in(adj, &mut adjusted, adjust::Transfer::for_document(doc.mode, depth), Some(depth), doc.mode);
for (b, a) in previous.px.iter_mut().zip(&adjusted.px) {
let blended = blend::blend_rgb(layer.blend, [b[0], b[1], b[2]], [a[0], a[1], a[2]]);
*b = [
b[0] + (blended[0] - b[0]) * layer.opacity,
b[1] + (blended[1] - b[1]) * layer.opacity,
b[2] + (blended[2] - b[2]) * layer.opacity,
b[3],
];
let (k, j) = (layer.opacity, 1.0 - layer.opacity);
*b = [b[0] * j + blended[0] * k, b[1] * j + blended[1] * k, b[2] * j + blended[2] * k, b[3]];
}
if let Some(q) = adjustment_quantum(depth) {
quantize(&mut previous, q);
@@ -4373,3 +4584,21 @@ fn branchless_floor_matches_floor_bitwise() {
assert_eq!(branchless_floor(x).to_bits(), x.floor().to_bits(), "{x:e} ({:#010x})", x.to_bits());
}
}
/// #9542: an adjustment layer's "This Layer" range judges its result at full strength, before
/// Opacity mixes it in. Photoshop: Invert at 40% over (64, 128, 192) with Red / This Layer black
/// at 150 keeps the effect, (115, 128, 140), as Invert's red (191) passes 150; with white at 150
/// it drops it. Judged after Opacity (red 115) the two came out the other way round.
#[test]
fn adjustment_blend_if_judges_the_result_before_opacity() {
for (black, white, want) in [(150u8, 255u8, [115, 128, 140]), (0, 150, [64, 128, 192])] {
let mut d = Document::with_background("bi", Size::new(1, 1), ColorMode::Rgb, SampleType::U8, Color::WHITE);
d.layers.push(solid_layer("base", Rect::new(0, 0, 1, 1), [64.0 / 255.0, 128.0 / 255.0, 192.0 / 255.0, 1.0]));
let mut inv = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert));
inv.opacity = 0.4;
inv.blend_if.set(1, [range([black, black], [white, white]), FULL]);
d.layers.push(inv);
let got: Vec<i32> = px(&d, 0, 0)[..3].iter().map(|v| (v * 255.0).round() as i32).collect();
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1), "black {black} white {white}: {got:?} vs Photoshop {want:?}");
}
}
+6
View File
@@ -101,6 +101,11 @@ pub struct CharStyle {
pub variations: Vec<FontVariation>,
/// BCP-47 language tag (affects shaping and line breaking).
pub language: Option<String>,
/// Vertical type: roman text (Latin, digits) runs rotated 90° clockwise along the column
/// (PSD `BaselineDirection` 2) instead of standing upright one character per em
/// (`BaselineDirection` 1, what Photoshop's Vertical Type tool sets, #9470). CJK text stands
/// upright either way.
pub rotate_roman: bool,
/// Applied character style (id in [`crate::TextStyles`]); `None` = "None". Attributes above
/// stay fully resolved; differences from the style are local overrides.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -135,6 +140,7 @@ impl Default for CharStyle {
features: Vec::new(),
variations: Vec::new(),
language: None,
rotate_roman: false,
style_sheet: None,
}
}
+47 -1
View File
@@ -115,6 +115,47 @@ pub struct ActionState {
pub take: u64,
}
/// Visibility gestures name a layer for replay, since document-local ids change on another image.
pub(crate) fn recorded_params(s: &Session, id: &str, mut p: Value) -> Value {
if s.actions.recording.is_none() || id != "layer.setProps" || p.get("visible").is_none() {
return p;
}
let Some(st) = s.active() else { return p };
let Some(layer) = p.get("layer").and_then(Value::as_u64).map(photocraft_doc::LayerId).or(st.active_layer).and_then(|id| st.doc.layer(id)) else { return p };
let ordinal = st.doc.walk().into_iter().filter(|(_, _, l)| l.name == layer.name).position(|(_, _, l)| l.id == layer.id).unwrap_or(0);
if let Some(object) = p.as_object_mut() {
object.remove("layer");
object.remove("coalesce");
object.insert("_actionLayer".into(), json!({"name": layer.name, "ordinal": ordinal}));
}
p
}
/// Resolve the recorded visibility target identically in engine and shell playback.
pub fn playback_params(s: &Session, id: &str, p: &Value) -> Result<Value> {
let mut p = p.clone();
if id == "layer.setProps"
&& let Some(target) = p.get("_actionLayer")
{
let name = target.get("name").and_then(Value::as_str).ok_or_else(|| bad(id, "missing recorded layer name"))?;
let ordinal =
target.get("ordinal").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(id, "invalid recorded layer ordinal"))?;
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let layer = doc
.walk()
.into_iter()
.filter(|(_, _, l)| l.name == name)
.nth(ordinal)
.map(|(_, _, l)| l.id)
.ok_or_else(|| bad(id, format!("recorded layer `{name}` does not exist")))?;
if let Some(object) = p.as_object_mut() {
object.remove("_actionLayer");
object.insert("layer".into(), json!(layer.0));
}
}
Ok(p)
}
impl ActionState {
pub(crate) fn touch(&mut self) {
self.rev = self.rev.saturating_add(1);
@@ -381,6 +422,11 @@ pub fn noop_step(s: &Session, id: &str, params: &Value) -> bool {
crate::commands::layer_param(s, params).is_ok_and(|layer| super::ops::arrange_at_end(&d.doc, layer, dir))
}
pub const SAVE_CONTENTS: &str = "layer.smartObjects.saveContents";
pub fn saves_smart_contents(s: &Session, id: &str) -> bool {
id == "file.save" && s.is_enabled(SAVE_CONTENTS)
}
fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<Value>) {
let mut ran = 0u64;
let mut failed = None;
@@ -399,7 +445,7 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<V
ran += 1;
continue;
}
match s.execute(id, params.clone()) {
match playback_params(s, id, params).and_then(|p| if saves_smart_contents(s, id) { s.execute(SAVE_CONTENTS, json!({})) } else { s.execute(id, p) }) {
Ok(value) => {
if let Some(error) = nested_failure(id, &value) {
failed = Some(json!({"step": i, "id": id, "error": error}));
+57 -3
View File
@@ -5,6 +5,30 @@ fn pixel(s: &Session, layer: usize, x: i32, y: i32) -> Vec<f32> {
s.active().unwrap().doc.layers[layer].surface().unwrap().pixel(x, y)
}
/// CopyCat's shape: Edit Contents, change the contents, Save. The Save step has no file to
/// write: it updates the smart object, headless too.
#[test]
fn save_in_edit_contents_updates_the_smart_object() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute("layer.smartObjects.editContents", json!({})).unwrap();
let steps = vec![("edit.fill".to_string(), json!({"contents": "color", "color": "#0000ff"})), ("file.save".to_string(), json!({}))];
s.actions.list.push(Action::new("Edit and save", steps));
let played = s.execute("actions.play", json!({"action": "Edit and save"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
s.set_active(0);
let doc = &s.active().unwrap().doc;
let flat = crate::file_cmds::flattened(doc, doc.pixel_format());
let p = flat.pixel(2, 2);
assert!(p[0] < 0.01 && p[2] > 0.99, "the smart object shows the saved contents: {p:?}");
// With no Edit Contents document, Save stays the shell's (it needs the document's file).
s.actions.list.push(Action::new("Save", vec![("file.save".to_string(), json!({}))]));
assert!(s.execute("actions.play", json!({"action": "Save"})).unwrap().get("failed").is_some());
}
#[test]
fn deselect_with_nothing_selected_is_a_noop_in_playback() {
// #6744: imported actions often start with Deselect; with no selection Photoshop plays on.
@@ -69,6 +93,27 @@ fn unchecked_steps_are_skipped_and_follow_move_and_delete() {
assert_eq!(s.actions.list[0].disabled.iter().copied().collect::<Vec<_>>(), [1], "rejected toggles change nothing");
}
#[test]
fn recorded_eye_visibility_replays_on_another_document_and_duplicate_names() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.new.layer", json!({"name": "Same"})).unwrap();
let hidden = s.execute("layer.new.layer", json!({"name": "Same"})).unwrap()["layer"].as_u64().unwrap();
s.execute("actions.record", json!({"name": "Hide"})).unwrap();
s.execute("layer.setProps", json!({"layer": hidden, "visible": false, "coalesce": "eye-sweep"})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
let keep = s.execute("layer.new.layer", json!({"name": "Same"})).unwrap()["layer"].as_u64().unwrap();
let target = s.execute("layer.new.layer", json!({"name": "Same"})).unwrap()["layer"].as_u64().unwrap();
s.execute("select.deselectLayers", json!({})).unwrap();
let result = s.execute("actions.play", json!({"action": "Hide"})).unwrap();
assert!(result.get("failed").is_none(), "{result}");
assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(keep)).unwrap().visible);
assert!(!s.active().unwrap().doc.layer(photocraft_doc::LayerId(target)).unwrap().visible);
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(target)).unwrap().visible);
}
#[test]
fn rename_keeps_calls_and_function_key_and_rejects_bad_names() {
let mut s = Session::new();
@@ -1095,7 +1140,7 @@ fn arrange_that_cannot_move_is_a_noop_in_playback() {
assert!(played.get("failed").is_none(), "{played}");
assert_eq!(s.active().unwrap().history.past_len(), before + 2, "the no-op adds no history step");
// The predicate agrees with the command for every layer: top level, above the Background,
// The predicate identifies no movement for every layer: top level, above the Background,
// and inside a group.
let a = s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].clone();
s.execute("layer.select", json!({"layer": a})).unwrap();
@@ -1107,9 +1152,18 @@ fn arrange_that_cannot_move_is_a_noop_in_playback() {
for id in ids.into_iter().filter(|id| Some(*id) != bg) {
for (cmd, dir) in cmds {
let at_end = super::super::ops::arrange_at_end(&s.active().unwrap().doc, id, dir);
let history = s.active().unwrap().history.past_len();
let ran = s.execute(cmd, json!({"layer": id.0}));
assert_eq!(at_end, ran.is_err(), "{cmd} on {id:?}: {ran:?}");
if ran.is_ok() {
if at_end {
if matches!(cmd, "layer.arrange.bringToFront" | "layer.arrange.sendToBack") {
assert_eq!(ran.unwrap(), json!({"moved": false}), "{cmd} on {id:?}");
} else {
assert!(ran.is_err(), "{cmd} on {id:?}: {ran:?}");
}
assert_eq!(s.active().unwrap().history.past_len(), history, "a no-op must not add history");
} else {
ran.unwrap();
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
s.execute("edit.undo", json!({})).unwrap();
}
}
@@ -219,6 +219,31 @@ LUT_3D_SIZE 2
assert_eq!(looks.as_array().unwrap().len(), photocraft_cms::lutfile::BUILTIN.len());
}
#[test]
fn color_lookup_mesh_3dl_honours_the_header() {
// #9078 / #9073: a Lustre .3dl whose `Mesh <inBits> <outBits>` line declares the cube edge
// (2^inBits + 1) and the output range (up to 2^outBits - 1). The three-value shaper row used
// to be counted as a 28th node ("28 rows is not a cube"), and 10-bit codes under a 12-bit
// declaration divided by the inferred 1023 instead of the declared 4095.
let mut lut = String::from("3DMESH\nMesh 1 12\n0 512 1023\n");
for r in 0..3 {
for g in 0..3 {
for b in 0..3 {
let v = [r, g, b].map(|c| (4095.0 - 4095.0 * c as f64 / 2.0).round() as i32);
lut.push_str(&format!("{} {} {}\n", v[0], v[1], v[2]));
}
}
}
let mut s = session(8, "rgb");
paint(&mut s, |_, _| [64.0 / 255.0, 128.0 / 255.0, 192.0 / 255.0, 1.0]);
s.execute("image.adjustments.colorLookup", json!({"data": lut, "fileName": "inverse.3dl", "dither": false})).unwrap();
// Photoshop 27.11 renders (64,128,192) as (191,127,63) through this file.
let after = rgba(&s, 5, 5);
for (c, want) in [(0, 191.0), (1, 127.0), (2, 63.0)] {
assert!((after[c] * 255.0 - want).abs() <= 1.0, "channel {c}: {after:?}");
}
}
/// The kind's parameters, read back from the model (Color Lookup with its table name and size).
#[test]
fn color_lookup_file_round_trips_psd_in_its_own_format() {
+25 -3
View File
@@ -1,3 +1,4 @@
const DIFFUSE_MODE: &[(&str, &str)] = &[("Nrml", "normal"), ("DrkO", "darkenOnly"), ("LghO", "lightenOnly"), ("Anst", "anisotropic")];
use super::adjustments::{adjustment_by_class, adjustment_steps, levels_from, levels_to, shadows_highlights_from, shadows_highlights_to};
use super::descriptors::{desc, get_bool, get_ref, jbool, layer_ref, layer_target, ref_class};
use super::fields::{F, Kind, Mapped, fields_from, fields_to, none, step};
@@ -14,7 +15,7 @@ use super::steps_layer::{
STROKE, apply_comp_from, apply_comp_to, clear_from, clear_to, delete_from, delete_to, duplicate_from, duplicate_to, fill_from, fill_to, flip_from, flip_to,
move_from, move_to, play_from, play_to, rotate_from, rotate_to, select_from, select_to, stroke_to, visibility_from, visibility_to,
};
use super::steps_make::{make_from, make_to, set_from, set_to};
use super::steps_make::{locking_from, locking_to, make_from, make_to, set_from, set_to};
use super::tables::{NOISE, OFFSET_FILL, SMART_BLUR_MODE, SMART_BLUR_QUALITY, TRIM};
use super::{AtnEvent, D, Descriptor, STOP_MESSAGE, Value, json};
@@ -248,6 +249,7 @@ pub(super) const EVENTS: &[Event] = &[
),
ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::ExactLong), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]),
ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::ExactLong)]),
ev!("Dfs ", "diffuse", "Diffuse", "filter.stylize.diffuse", &[F("Md ", "mode", Kind::Enum("DfsM", DIFFUSE_MODE)), F("FlRs", "seed", Kind::Long)]),
Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to },
// Adjustments (Image › Adjustments).
Event {
@@ -385,6 +387,7 @@ pub(super) const EVENTS: &[Event] = &[
"layer.layerMask.hideSelection",
"file.new",
"file.newFromClipboard",
"history.newSnapshot",
],
from: make_from,
to: make_to,
@@ -408,6 +411,8 @@ pub(super) const EVENTS: &[Event] = &[
"layer.setAdjustment",
"layer.fill.pattern.set",
"layer.new.layerFromBackground",
"layer.layerStyle.blendingOptions",
"layer.smartFilter.blendingOptions",
],
from: set_from,
to: set_to,
@@ -436,8 +441,25 @@ pub(super) const EVENTS: &[Event] = &[
from: select_from,
to: select_to,
},
Event { code: "Dlt ", name: "delete", label: "Delete", ids: &["layer.delete", "edit.clear", "layer.layerMask.delete"], from: delete_from, to: delete_to },
Event { code: "", name: "clearEvent", label: "Clear", ids: &["select.editInQuickMaskMode"], from: clear_from, to: clear_to },
Event {
code: "Dlt ",
name: "delete",
label: "Delete",
ids: &["layer.delete", "edit.clear", "layer.layerMask.delete", "layer.smartFilter.deleteFilterMask"],
from: delete_from,
to: delete_to,
},
Event { code: "", name: "applyLocking", label: "Apply Locking", ids: &["layer.lockLayers"], from: locking_from, to: locking_to },
// Timeline › Make Frames from Layers: no frame animation in PhotoCraft, no document change.
Event {
code: "",
name: "animationFramesFromLayers",
label: "Make Frames from Layers",
ids: &[],
from: |_| Mapped::Unsupported { harmless: true },
to: |_, _| None,
},
Event { code: "", name: "clearEvent", label: "Clear", ids: &["select.editInQuickMaskMode", "history.clear"], from: clear_from, to: clear_to },
Event {
code: "Trnf",
name: "transform",
@@ -286,6 +286,8 @@ const REFINE_OUTPUT: &[(&str, &str)] = &[
("refineEdgeOutputNewLayerWithMask", "newLayerWithMask"),
("refineEdgeOutputNewDocument", "newDocument"),
("refineEdgeOutputNewDocumentWithMask", "newDocumentWithMask"),
// Newer Photoshop's name for Output To: Selection.
("selectionOutputToSelection", "selection"),
];
/// Filter Gallery effects (`GEfc` with the effect in `GEfk`): Photoshop's effect code, the
@@ -117,6 +117,15 @@ pub(super) fn delete_from(d: &Descriptor) -> Mapped {
// The targeted channel of a layer is its mask (what Photoshop records after making an
// adjustment layer over a selection and discarding the mask).
Some(r) if ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Ordinal(o) if o == "Trgt") => none("layer.layerMask.delete"),
// The mask Photoshop adds to a new fill or adjustment layer, or to a smart object's filters.
Some(r) if ref_class(r).as_deref() == Some("Chnl") => match target(r) {
Target::Ordinal(o) if o == "Msk" => none("layer.layerMask.delete"),
// Photoshop adds an empty filter mask with the first smart filter; PhotoCraft doesn't.
Target::Ordinal(o) if o == "filterMask" => step("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})),
_ => Mapped::Unsupported { harmless: false },
},
// Timeline frames: PhotoCraft has no frame animation, so there is nothing to delete.
Some(r) if ref_class(r).as_deref() == Some("animationClass") => Mapped::Unsupported { harmless: true },
Some(_) => Mapped::Unsupported { harmless: false },
}
}
@@ -131,6 +140,8 @@ pub(super) fn quick_mask_ref() -> D {
pub(super) fn clear_from(d: &Descriptor) -> Mapped {
match get_ref(d, "null").map(target) {
Some(Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": false})),
// History panel menu › Clear History.
Some(Target::Property(p)) if p == "HsSt" => none("history.clear"),
None => none("edit.clear"),
_ => Mapped::Unsupported { harmless: false },
}
@@ -139,6 +150,13 @@ pub(super) fn clear_from(d: &Descriptor) -> Mapped {
pub(super) fn clear_to(id: &str, p: &Value) -> Option<Descriptor> {
match id {
"select.editInQuickMaskMode" if jbool(p, "on") == Some(false) => Some(desc("null").with("null", quick_mask_ref())),
"history.clear" => Some(desc("null").with(
"null",
D::Reference(vec![
ReferenceItem::Property { class: class("Prpr"), key: code("HsSt") },
ReferenceItem::Enumerated { class: class("Dcmn"), type_id: code("Ordn"), value: code("Trgt") },
]),
)),
_ => None,
}
}
@@ -148,6 +166,8 @@ pub(super) fn delete_to(id: &str, p: &Value) -> Option<Descriptor> {
"edit.clear" => desc("null"),
"layer.delete" => desc("null").with("null", layer_ref(p)?),
"layer.layerMask.delete" => desc("null").with("null", target_ref("Chnl")),
"layer.smartFilter.deleteFilterMask" => desc("null")
.with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: Id::new("filterMask") }])),
_ => return None,
})
}
+232 -54
View File
@@ -136,6 +136,9 @@ fn new_document_from_descriptor(d: &Descriptor) -> Option<Mapped> {
Some(step("file.new", params))
}
/// New Snapshot's From: the full document, the layers merged, or the current layer.
const SNAPSHOT_FROM: &[(&str, &str)] = &[("FllD", "fullDocument"), ("MrgL", "mergedLayers"), ("CrnL", "currentLayer")];
pub(super) fn make_from(d: &Descriptor) -> Mapped {
if let Some(mapped) = duplicate_layer_from_descriptor(d) {
return mapped;
@@ -198,6 +201,24 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped {
None => step("layer.new.group", Value::Object(p)),
}
}
// History panel › New Snapshot (of the current state).
Some("SnpS") => {
let mut p = Map::new();
if let Some(n) = get_text(d, "Nm ") {
p.insert("name".into(), n.into());
}
match get_enum(d, "Usng").as_deref().map(|u| lookup(SNAPSHOT_FROM, u)) {
None => {}
Some(Some(f)) => {
p.insert("from".into(), f.into());
}
Some(None) => return Mapped::Unsupported { harmless: false },
}
if get_bool(d, "replaceExisting") == Some(true) {
p.insert("replaceExisting".into(), true.into());
}
step("history.newSnapshot", Value::Object(p))
}
_ => Mapped::Unsupported { harmless: false },
}
}
@@ -286,6 +307,18 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
desc("null").with("Nw ", D::Class(class("Chnl"))).with("At ", at).with("Usng", enm("UsrM", lookup_back(MASK_USING, id)?))
}
"file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))),
"history.newSnapshot" => {
let current = D::Reference(vec![ReferenceItem::Property { class: class("HstS"), key: code("CrnH") }]);
let mut d = desc("null").with("null", class_ref("SnpS")).with("From", current);
if let Some(n) = jstr(p, "name") {
d = d.with("Nm ", text(n));
}
d = d.with("Usng", enm("HstS", lookup_back(SNAPSHOT_FROM, jstr(p, "from").unwrap_or("fullDocument"))?));
if jbool(p, "replaceExisting") == Some(true) {
d = d.with("replaceExisting", D::Boolean(true));
}
d
}
"file.new" => {
let mut n = desc("Dcmn");
if let Some(name) = jstr(p, "name") {
@@ -401,6 +434,121 @@ fn background_from(d: &Descriptor) -> Mapped {
steps(v)
}
/// The Layers panel lock buttons (`layerLocking`) ↔ `layer.lockLayers` params.
const LOCKS: &[(&str, &str)] = &[
("protectTransparency", "transparency"),
("protectComposite", "pixels"),
("protectPosition", "position"),
("protectArtboardAutonest", "artboard"),
("protectAll", "all"),
];
/// Blending Options' Advanced checkboxes ↔ `layer.layerStyle.blendingOptions` params.
const ADVANCED_BLENDING: &[(&str, &str)] = &[("blendClipped", "blendClippedLayersAsGroup"), ("transparencyShapesLayer", "transparencyShapesLayer")];
fn lock_params(locks: &Descriptor) -> Option<Map<String, Value>> {
let mut m = Map::new();
// "Unlock all" clears every lock.
if get_bool(locks, "protectNone") == Some(true) {
for (_, param) in LOCKS {
m.insert((*param).into(), false.into());
}
}
for (key, param) in LOCKS {
if let Some(b) = get_bool(locks, key) {
m.insert((*param).into(), b.into());
}
}
(!m.is_empty()).then_some(m)
}
/// `applyLocking`: a lock button in the Layers panel, on the target layer.
pub(super) fn locking_from(d: &Descriptor) -> Mapped {
let on_target = get_ref(d, "null").is_none_or(|r| ref_class(r).as_deref() == Some("Lyr") && matches!(target(r), Target::Ordinal(o) if o == "Trgt"));
match get_obj(d, "layerLocking").and_then(lock_params) {
Some(l) if on_target => step("layer.lockLayers", Value::Object(l)),
_ => Mapped::Unsupported { harmless: false },
}
}
pub(super) fn locking_to(_: &str, p: &Value) -> Option<Descriptor> {
let mut locks = desc("layerLocking");
for (key, param) in LOCKS {
if let Some(b) = jbool(p, param) {
locks = locks.with(key, D::Boolean(b));
}
}
if locks.items.is_empty() {
return None;
}
Some(desc("null").with("null", target_ref("Lyr ")).with("layerLocking", D::Descriptor(locks)))
}
fn layer_set_from(d: &Descriptor, r: &[ReferenceItem]) -> Mapped {
let unsupported = Mapped::Unsupported { harmless: false };
let Some(mut p) = layer_target(r) else { return unsupported };
let Some(t) = get_obj(d, "T ") else { return unsupported };
let mut v = Vec::new();
if let Some(c) = get_enum(t, "Clr ").and_then(|c| lookup(LABEL_COLORS, &c)) {
let mut lp = p.clone();
lp.insert("color".into(), c.into());
v.push(("layer.setLabelColor", Value::Object(lp)));
}
if let Some(n) = get_text(t, "Nm ") {
p.insert("name".into(), n.into());
}
if let Some(o) = get_num(t, "Opct") {
p.insert("opacity".into(), (o / 100.0).into());
}
if let Some(f) = get_num(t, "fillOpacity") {
p.insert("fill".into(), (f / 100.0).into());
}
if let Some(m) = get_enum(t, "Md ") {
let Some(label) = blend_label(&m) else { return unsupported };
p.insert("blend".into(), label.into());
}
if let Some(vis) = get_bool(t, "Vsbl") {
p.insert("visible".into(), vis.into());
}
// Lock buttons and Blending Options' Advanced checkboxes act on the target layer.
let targeted = !p.contains_key("layer");
if let Some(locks) = get_obj(t, "layerLocking") {
match lock_params(locks) {
Some(l) if targeted => v.push(("layer.lockLayers", Value::Object(l))),
_ => return unsupported,
}
}
let mut advanced = Map::new();
for (key, param) in ADVANCED_BLENDING {
if let Some(b) = get_bool(t, key) {
advanced.insert((*param).into(), b.into());
}
}
if !advanced.is_empty() {
if !targeted {
return unsupported;
}
v.push(("layer.layerStyle.blendingOptions", Value::Object(advanced)));
}
if p.keys().any(|k| k != "layer") {
v.push(("layer.setProps", Value::Object(p)));
}
// The Layer Style dialog's OK on a layer without effects records only the effects'
// scale (`Lefx` = {Scl}); an empty `T` is an OK that changed nothing. Neither changes
// the document.
let no_effect = t.items.iter().all(|(k, val)| match val {
D::Descriptor(fx) if k.is("Lefx") => fx.items.iter().all(|(fk, _)| fk.is("Scl ")),
_ => false,
});
if !v.is_empty() {
steps(v)
} else if no_effect {
Mapped::Unsupported { harmless: true }
} else {
unsupported
}
}
pub(super) fn set_from(d: &Descriptor) -> Mapped {
let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } };
let unsupported = Mapped::Unsupported { harmless: false };
@@ -416,35 +564,28 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped {
Some(hex) => step("tools.setColors", json!({(if p == "FrgC" { "foreground" } else { "background" }): hex})),
None => unsupported,
},
(Some("Lyr"), _) => {
let Some(mut p) = layer_target(r) else { return unsupported };
let Some(t) = get_obj(d, "T ") else { return unsupported };
let mut v = Vec::new();
if let Some(c) = get_enum(t, "Clr ").and_then(|c| lookup(LABEL_COLORS, &c)) {
let mut lp = p.clone();
lp.insert("color".into(), c.into());
v.push(("layer.setLabelColor", Value::Object(lp)));
(Some("Lyr"), _) => layer_set_from(d, r),
// Layer › Smart Filter › Blending Options (`indx` counts the filters from 1, bottom first).
(Some("filterFX"), _) => {
let index = match r.first() {
Some(ReferenceItem::Index { index, .. }) if *index >= 1 => index - 1,
_ => return unsupported,
};
let on_target =
matches!(r.get(1..), Some(rest) if ref_class(rest).as_deref() == Some("Lyr") && matches!(target(rest), Target::Ordinal(o) if o == "Trgt"));
if !on_target {
return unsupported;
}
if let Some(n) = get_text(t, "Nm ") {
p.insert("name".into(), n.into());
let Some(opts) = get_obj(d, "filterFX").and_then(|f| get_obj(f, "blendOptions")) else { return unsupported };
let mut p = json!({"index": index});
if let Some(o) = get_num(opts, "Opct") {
p["opacity"] = json!(o / 100.0);
}
if let Some(o) = get_num(t, "Opct") {
p.insert("opacity".into(), (o / 100.0).into());
}
if let Some(f) = get_num(t, "fillOpacity") {
p.insert("fill".into(), (f / 100.0).into());
}
if let Some(m) = get_enum(t, "Md ") {
if let Some(m) = get_enum(opts, "Md ") {
let Some(label) = blend_label(&m) else { return unsupported };
p.insert("blend".into(), label.into());
p["blend"] = json!(label);
}
if let Some(vis) = get_bool(t, "Vsbl") {
p.insert("visible".into(), vis.into());
}
if p.keys().any(|k| k != "layer") {
v.push(("layer.setProps", Value::Object(p)));
}
if v.is_empty() { unsupported } else { steps(v) }
step("layer.smartFilter.blendingOptions", p)
}
// A Pattern Fill layer's pattern (double-click its thumbnail).
(Some("contentLayer"), Target::Ordinal(o)) if o == "Trgt" => match get_obj(d, "T ") {
@@ -475,6 +616,38 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped {
}
}
fn layer_set_to(p: &Value) -> Option<Descriptor> {
// Only these keys map onto the `Lyr ` descriptor; locks, clipping, Advanced Blending
// channels or an explicit `layers` list would be dropped (#7196, #7246).
const MAPPED: [&str; 7] = ["layer", "name", "opacity", "fill", "blend", "visible", "color"];
if p.as_object().is_some_and(|m| m.iter().any(|(k, v)| !v.is_null() && !MAPPED.contains(&k.as_str()))) {
return None;
}
let mut t = desc("Lyr ");
if let Some(n) = jstr(p, "name") {
t = t.with("Nm ", text(n));
}
if let Some(o) = jnum(p, "opacity") {
t = t.with("Opct", pct(o * 100.0));
}
if let Some(f) = jnum(p, "fill") {
t = t.with("fillOpacity", pct(f * 100.0));
}
if let Some(m) = jstr(p, "blend") {
t = t.with("Md ", enm("BlnM", blend_ps(m)?));
}
if let Some(v) = jbool(p, "visible") {
t = t.with("Vsbl", D::Boolean(v));
}
if let Some(c) = jstr(p, "color") {
t = t.with("Clr ", enm("Clr ", lookup_back(LABEL_COLORS, c)?));
}
if t.items.is_empty() {
return None;
}
Some(desc("null").with("null", layer_ref(p)?).with("T ", D::Descriptor(t)))
}
pub(super) fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
let fsel = D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]);
let sel = |t: D| desc("null").with("null", fsel.clone()).with("T ", t);
@@ -538,35 +711,7 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
sel(D::Descriptor(desc(cls).with("Top ", px(y)).with("Left", px(x)).with("Btom", px(y + h)).with("Rght", px(x + w))))
}
"layer.setProps" | "layer.renameLayer" | "layer.setLabelColor" => {
// Only these keys map onto the `Lyr ` descriptor; locks, clipping, Advanced Blending
// channels or an explicit `layers` list would be dropped (#7196, #7246).
const MAPPED: [&str; 7] = ["layer", "name", "opacity", "fill", "blend", "visible", "color"];
if p.as_object().is_some_and(|m| m.iter().any(|(k, v)| !v.is_null() && !MAPPED.contains(&k.as_str()))) {
return None;
}
let mut t = desc("Lyr ");
if let Some(n) = jstr(p, "name") {
t = t.with("Nm ", text(n));
}
if let Some(o) = jnum(p, "opacity") {
t = t.with("Opct", pct(o * 100.0));
}
if let Some(f) = jnum(p, "fill") {
t = t.with("fillOpacity", pct(f * 100.0));
}
if let Some(m) = jstr(p, "blend") {
t = t.with("Md ", enm("BlnM", blend_ps(m)?));
}
if let Some(v) = jbool(p, "visible") {
t = t.with("Vsbl", D::Boolean(v));
}
if let Some(c) = jstr(p, "color") {
t = t.with("Clr ", enm("Clr ", lookup_back(LABEL_COLORS, c)?));
}
if t.items.is_empty() {
return None;
}
desc("null").with("null", layer_ref(p)?).with("T ", D::Descriptor(t))
return layer_set_to(p);
}
"layer.new.layerFromBackground" => {
let bckg = D::Reference(vec![ReferenceItem::Property { class: class("Lyr "), key: Id::new("Bckg") }]);
@@ -578,6 +723,39 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
}
desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(pattern_desc(p)?))
}
"layer.layerStyle.blendingOptions" => {
let o = p.as_object()?;
let mut t = desc("Lyr ");
for (key, param) in ADVANCED_BLENDING {
if let Some(b) = jbool(p, param) {
t = t.with(key, D::Boolean(b));
}
}
// Only the Advanced checkboxes export: Photoshop records the dialog's other options
// differently.
if t.items.is_empty() || t.items.len() != o.len() {
return None;
}
desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t))
}
"layer.smartFilter.blendingOptions" => {
if p.get("layer").is_some() {
return None;
}
let index = u32::try_from(p.get("index")?.as_u64()?).ok()?.checked_add(1)?;
let mut opts = desc("blendOptions");
if let Some(o) = jnum(p, "opacity") {
opts = opts.with("Opct", pct(o * 100.0));
}
if let Some(m) = jstr(p, "blend") {
opts = opts.with("Md ", enm("BlnM", blend_ps(m)?));
}
let r = D::Reference(vec![
ReferenceItem::Index { class: class("filterFX"), index },
ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Trgt") },
]);
desc("null").with("null", r).with("filterFX", D::Descriptor(desc("filterFX").with("blendOptions", D::Descriptor(opts))))
}
"layer.setAdjustment" => {
// The adjustment kind isn't in the params: Photoshop needs it, so only steps that
// name one (`kind`) export.
+10
View File
@@ -654,6 +654,7 @@ fn every_mapped_command_exports_and_imports_back() {
("edit.undo", json!({})),
("edit.redo", json!({})),
("layer.select", json!({"layer": "YELLOW", "mode": "remove"})),
("layer.select", json!({"layer": "Or paste photocopy texture here", "mode": "range"})),
("layer.translate", json!({"dx": 0.36, "dy": -1.08, "units": "pt"})),
("select.loadSelection", json!({"channel": "targeted"})),
("layer.newFillLayer.pattern", json!({"pattern": "Debaser-Distressed-Dark", "scale": 50, "angle": 0, "link": true})),
@@ -661,6 +662,15 @@ fn every_mapped_command_exports_and_imports_back() {
("image.mode.bitmap", json!({"method": "threshold"})),
("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true})),
("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false})),
// COPYCAT, AJ_Actions2023 and the Line and Color tutorial set.
("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})),
("layer.smartFilter.blendingOptions", json!({"index": 1, "opacity": 0.5, "blend": "Darken"})),
("layer.layerStyle.blendingOptions", json!({"blendClippedLayersAsGroup": false, "transparencyShapesLayer": false})),
("layer.createClippingMask", json!({})),
("layer.lockLayers", json!({"transparency": true})),
("filter.stylize.diffuse", json!({"mode": "normal", "seed": 321654})),
("history.clear", json!({})),
("history.newSnapshot", json!({"name": "CopyCat - action result", "from": "fullDocument", "replaceExisting": true})),
("layerComp.apply", json!({"comp": "Comp 1"})),
];
for (id, params) in &cases {
+13 -1
View File
@@ -375,7 +375,19 @@ fn write_shim(droplet: &str) -> Result<String> {
// quoted segment, emit a literal quote, then resume it.
let quoted_abs = format!("'{}'", abs.replace('\'', "'\\''"));
let body = format!(
"#!/bin/sh\n# PhotoCraft droplet: runs the action on the files given (or dropped).\nexec \"${{PHOTOCRAFT_CLI:-photocraft-cli}}\" droplet {quoted_abs} \"$@\"\n"
r#"#!/bin/sh
# PhotoCraft droplet: runs the action on the files given (or dropped).
if [ -n "${{PHOTOCRAFT_CLI:-}}" ]; then
cli="$PHOTOCRAFT_CLI"
elif [ -x /Applications/PhotoCraft.app/Contents/MacOS/photocraft-cli ]; then
cli=/Applications/PhotoCraft.app/Contents/MacOS/photocraft-cli
elif [ -x "$HOME/Applications/PhotoCraft.app/Contents/MacOS/photocraft-cli" ]; then
cli="$HOME/Applications/PhotoCraft.app/Contents/MacOS/photocraft-cli"
else
cli=photocraft-cli
fi
exec "$cli" droplet {quoted_abs} "$@"
"#
);
crate::file_cmds::write_file(&shim, body.as_bytes())?;
#[cfg(unix)]
@@ -156,6 +156,8 @@ fn droplets_are_written_and_run() {
let shim = r["shim"].as_str().unwrap();
let text = std::fs::read_to_string(shim).unwrap();
assert!(text.starts_with("#!/bin/sh") && text.contains("droplet"));
assert!(text.contains("/Applications/PhotoCraft.app/Contents/MacOS/photocraft-cli"));
assert!(text.contains("${PHOTOCRAFT_CLI:-}"), "an explicit CLI path must take priority");
use std::os::unix::fs::PermissionsExt;
assert_ne!(std::fs::metadata(shim).unwrap().permissions().mode() & 0o111, 0, "executable");
}
+41 -2
View File
@@ -410,14 +410,19 @@ fn clear_mode(p: &Value) -> bool {
}
/// Applies the options-bar blend `mode` to a brush. `"mode"` accepts any blend-mode name
/// (normal|multiply|screen|…), or `clear` to erase with the brush. The Eraser has no blend mode in
/// (normal|multiply|screen|…), or `behind` to paint beneath existing pixels, or `clear` to erase with the brush. The Eraser has no blend mode in
/// Photoshop, so it is forced to Normal.
fn with_blend_mode(mut b: BrushSettings, p: &Value) -> BrushSettings {
b.erase |= clear_mode(p);
if b.erase {
b.mode = photocraft_color::BlendMode::Normal;
b.behind = false;
} else if p.get("mode").and_then(Value::as_str).is_some_and(|m| m.eq_ignore_ascii_case("behind")) {
b.mode = photocraft_color::BlendMode::Normal;
b.behind = true;
} else if let Some(m) = p.get("mode").and_then(Value::as_str).and_then(crate::commands::blend_from_str) {
b.mode = m;
b.behind = false;
}
b
}
@@ -789,6 +794,32 @@ fn mixer_clean(s: &mut Session, _: &Value) -> Result<Value> {
Ok(serde_json::to_value(&s.tools.mixer).unwrap_or_default())
}
/// `paint.mixerLoad`: ⌥-click with the Mixer Brush loads the reservoir from the canvas colour at
/// (`x`, `y`) (Photoshop's Current Brush Load), from the layers Sample All Layers names. The
/// foreground colour is left alone. Over transparency or off the canvas nothing is loaded.
fn mixer_load_pixel(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "paint.mixerLoad";
let coord = |k: &str| {
let v = p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
v.ok_or_else(|| bad(cmd, format!("`{k}` must be a finite number (document pixels)")))
};
let (x, y) = (coord("x")?, coord("y")?);
let size = crate::sample_cmds::size_param(cmd, p)?;
let which = if flag(p, "sampleAllLayers", s.tools.brush.mixer.sample_all_layers) {
crate::sample_cmds::SampleLayers::All
} else {
crate::sample_cmds::SampleLayers::Current
};
let d = s.active().ok_or(EngineError::NoDocument)?;
let [r, g, b, a] = crate::sample_cmds::sample_color(&d.doc, d.active_layer, x, y, size, which);
if a.is_nan() || a <= 0.0 {
return Ok(json!({"loaded": false}));
}
let foreground = s.tools.foreground;
s.tools.mixer.load_sampled([r, g, b, 1.0], foreground);
Ok(json!({"loaded": true, "color": [r, g, b, 1.0]}))
}
/// `paint.colorReplacement`'s stroke and settings. The replacement colour is the brush colour
/// (the foreground unless `color` says otherwise). It paints layer pixels only: a mask, alpha
/// channel or Quick Mask `target` is refused rather than recolouring the layer instead.
@@ -1271,7 +1302,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"paint.pencil",
"Pencil",
r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"block":bool=false (a hard 16×16 screen-px square, 16/zoom doc px; size, opacity, flow, hardness and dynamics ignored: the Eraser's Block mode),"zoom":view zoom=1,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##,
r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|behind (paints beneath existing pixels)|clear (erases with this brush)"="normal","erase":bool?,"block":bool=false (a hard 16×16 screen-px square, 16/zoom doc px; size, opacity, flow, hardness and dynamics ignored: the Eraser's Block mode),"zoom":view zoom=1,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##,
has_paintable,
pencil,
true
@@ -1286,6 +1317,14 @@ pub fn specs() -> Vec<CommandSpec> {
),
spec!("brush.mixer.load", "Load Mixer Brush", r##"{"color":"#rrggbb"|[r,g,b,a?] 0..1?=foreground}"##, always, mixer_load, true),
spec!("brush.mixer.clean", "Clean Mixer Brush", "{}", always, mixer_clean, true),
spec!(
"paint.mixerLoad",
"Load Mixer Brush",
r##"{"x":px,"y":px,"size":"point"|1|3|5|11|31|51|101=1,"sampleAllLayers":bool=brush.mixer.sampleAllLayers} → {"loaded":bool,"color"?:[r,g,b,a]} (loads the Mixer Brush reservoir from the canvas colour there, as ⌥-click does; the foreground colour is unchanged; the next strokes keep that load while the foreground stays as it is)"##,
has_paintable,
mixer_load_pixel,
true
),
spec!(
"paint.colorReplacement",
"Color Replacement",
@@ -30,6 +30,7 @@ fn valid_state(state: &MixerState) -> bool {
&& state.reservoir.as_ref().is_none_or(valid_color)
&& state.pickup.as_ref().is_none_or(valid_color)
&& state.color.as_ref().is_none_or(valid_color)
&& state.sampled_over.as_ref().is_none_or(valid_color)
&& state.spatial.as_ref().is_none_or(|colors| colors.len() == 4096 && colors.iter().all(valid_color))
}
+50
View File
@@ -1407,6 +1407,23 @@ fn clear_mode_erases_with_the_brush_or_pencil() {
}
}
#[test]
fn behind_brush_and_pencil_preserve_foreground_and_round_trip_history() {
for cmd in ["paint.stroke", "paint.pencil"] {
let mut s = session(40, 20);
s.execute(cmd, json!({"points": [[10, 10]], "size": 6, "color": "#ff0000", "hardness": 1.0})).unwrap();
let before = surface(&s);
s.execute(cmd, json!({"points": [[2, 10], [38, 10]], "size": 6, "color": "#0000ff", "mode": "behind", "hardness": 1.0})).unwrap();
assert_eq!(rgba(&s, 10, 10), [1.0, 0.0, 0.0, 1.0], "{cmd}: existing opaque paint");
assert_eq!(rgba(&s, 25, 10), [0.0, 0.0, 1.0, 1.0], "{cmd}: transparent pixels");
let painted = surface(&s);
s.execute("edit.undo", json!({})).unwrap();
assert!(same_pixels(&surface(&s), &before, Rect::new(0, 0, 40, 20)));
s.execute("edit.redo", json!({})).unwrap();
assert!(same_pixels(&surface(&s), &painted, Rect::new(0, 0, 40, 20)));
}
}
/// #4344: a foreground picked as CMYK 100/0/0/0 is painted into a CMYK document as those inks by
/// the Brush and the Pencil, not as whatever its sRGB stand-in converts back to (100/28/0/17).
#[test]
@@ -1424,3 +1441,36 @@ fn brush_and_pencil_paint_a_cmyk_pick_with_the_picked_inks() {
assert!(got.iter().zip(cyan).all(|(a, b)| (a - b).abs() < 0.02), "{cmd}: {got:?}");
}
}
#[test]
fn mixer_load_fills_the_reservoir_from_the_canvas_and_keeps_the_foreground() {
let mut s = session(80, 40);
s.execute("paint.stroke", json!({"points": [[4, 20], [60, 20]], "size": 6, "color": "#0000ff"})).unwrap();
s.execute("tools.setColors", json!({"foreground": "#ff0000"})).unwrap();
// Over transparency nothing is loaded.
let r = s.execute("paint.mixerLoad", json!({"x": 30, "y": 5})).unwrap();
assert_eq!(r["loaded"], json!(false));
assert!(s.tools.mixer.reservoir.is_none());
// On the blue stroke the reservoir takes its colour; the foreground stays red.
let r = s.execute("paint.mixerLoad", json!({"x": 30, "y": 20})).unwrap();
assert_eq!(r["loaded"], json!(true));
assert_eq!(s.tools.mixer.reservoir, Some([0.0, 0.0, 1.0, 1.0]));
assert_eq!(s.tools.foreground, [1.0, 0.0, 0.0, 1.0]);
// The next dry stroke paints the sampled blue, not the foreground.
let brush = json!({"pressureSize": false, "spacing": 0.25});
let dry = |y: i32, size: u32| json!({"points": [[5, y], [40, y]], "size": size, "wet": 0, "load": 100, "mix": 0, "brush": brush});
s.execute("paint.mixerBrush", dry(30, 8)).unwrap();
assert_eq!(rgba(&s, 20, 30), [0.0, 0.0, 1.0, 1.0]);
// A new foreground colour takes the reservoir back.
s.execute("tools.setColors", json!({"foreground": "#00ff00"})).unwrap();
s.execute("paint.mixerBrush", dry(36, 4)).unwrap();
assert_eq!(rgba(&s, 20, 36), [0.0, 1.0, 0.0, 1.0]);
// Bad coordinates are an error, not a panic, and leave the reservoir, the foreground and the
// document untouched.
let (mixer, foreground) = (s.tools.mixer.clone(), s.tools.foreground);
let revision = s.active().unwrap().revision;
assert!(s.execute("paint.mixerLoad", json!({"x": "a", "y": 1})).is_err());
assert_eq!(s.tools.mixer, mixer);
assert_eq!(s.tools.foreground, foreground);
assert_eq!(s.active().unwrap().revision, revision);
}
+4 -1
View File
@@ -1675,7 +1675,10 @@ mod settings_tests {
fn exif_srgb_jpeg_round_trip() {
let mut s = Session::new();
s.execute("edit.colorSettings", json!({"workingRgb": "display-p3"})).unwrap();
let r = photocraft_io::export(&untagged_camera_file(Some(1)), "x.jpg", &photocraft_io::ExportOptions::default()).unwrap();
// Embed Color Profile off: an untagged document is otherwise tagged sRGB on export (#9325).
let mut opts = photocraft_io::ExportOptions::default();
opts.encode.embed_icc = false;
let r = photocraft_io::export(&untagged_camera_file(Some(1)), "x.jpg", &opts).unwrap();
let back = photocraft_io::import("x.jpg", &r.bytes).unwrap().document;
assert!(back.icc_profile.is_none(), "no ICC profile in the file");
let (_, r) = s.open_document(back, None);
+25 -4
View File
@@ -916,7 +916,18 @@ fn edit_guide(s: &mut Session, p: &Value) -> Result<Value> {
/// View › New Guide / guide moves (undoable, like Photoshop's "New Guide"/"Move Guide" states).
fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
let vertical = p.get("orientation").and_then(Value::as_str).unwrap_or("horizontal") == "vertical";
let pos = guide_position(s, p, vertical)?;
// Move Guide can turn the guide (⌥ while dragging it, as in Photoshop): `toOrientation`.
let to_vertical = match p.get("toOrientation").map(|v| v.as_str()) {
None => vertical,
Some(Some("vertical")) => true,
Some(Some("horizontal")) => false,
Some(_) => return Err(EngineError::Other("`toOrientation` must be \"horizontal\" or \"vertical\"".into())),
};
if to_vertical != vertical && op != "move" {
return Err(EngineError::Other("`toOrientation` applies to view.moveGuide only".into()));
}
// A % position is of the axis the guide ends up on.
let pos = guide_position(s, p, to_vertical)?;
let color = if op == "new" { guide_color(p)? } else { None };
let index = p.get("index").and_then(Value::as_u64).map(|v| v as usize);
let label = match op {
@@ -939,10 +950,20 @@ fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
"move" => {
let i = index.filter(|i| *i < g.len()).ok_or(EngineError::Other("no such guide".into()))?;
let to = pos.ok_or(EngineError::Other("missing `position`".into()))?;
let from = std::mem::replace(&mut g[i], to);
let from = if to_vertical == vertical {
std::mem::replace(&mut g[i], to)
} else {
let from = g.remove(i);
if to_vertical {
guides.vertical.push(to)
} else {
guides.horizontal.push(to)
}
from
};
// The guide takes its colour along; another guide left at the old position keeps it.
if let Some(rgb) = guides.color(vertical, from) {
guides.set_color(vertical, to, Some(rgb));
guides.set_color(to_vertical, to, Some(rgb));
}
guides.prune_colors();
}
@@ -1088,7 +1109,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Guide",
&[],
None,
r##"{"orientation":"horizontal|vertical","index":n,"position":px or "<number><unit>"} (units as for view.newGuide)"##,
r##"{"orientation":"horizontal|vertical","index":n,"position":px or "<number><unit>","toOrientation":"horizontal|vertical"?} (units as for view.newGuide; toOrientation turns the guide, its position then on the new axis)"##,
has_doc,
|s, p| guide_cmd(s, p, "move")
),
+29
View File
@@ -1191,6 +1191,35 @@ fn guides_are_undoable() {
assert!(s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 3, "position": 1})).is_err());
}
/// #5034: Move Guide turns a guide with `toOrientation` (⌥ while dragging it), in one undo
/// step, taking its colour along; a % position is of the new axis.
#[test]
fn move_guide_turns_a_guide() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 100})).unwrap();
s.execute("view.newGuide", json!({"orientation": "horizontal", "position": 30, "color": "magenta"})).unwrap();
s.execute("view.newGuide", json!({"orientation": "horizontal", "position": 70})).unwrap();
s.execute("view.moveGuide", json!({"orientation": "horizontal", "index": 0, "position": "50%", "toOrientation": "vertical"})).unwrap();
let g = &s.active().unwrap().doc.guides;
assert_eq!((g.horizontal.clone(), g.vertical.clone()), (vec![70.0], vec![100.0]), "50% of the width");
assert_eq!(g.color(true, 100.0), Some([255, 0, 255]), "the colour moves with the guide");
assert_eq!(g.color(false, 30.0), None);
// The same orientation is a plain move.
s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 0, "position": 20, "toOrientation": "vertical"})).unwrap();
assert_eq!(s.active().unwrap().doc.guides.vertical, vec![20.0]);
s.undo();
s.undo();
let g = &s.active().unwrap().doc.guides;
assert_eq!((g.horizontal.clone(), g.vertical.clone()), (vec![30.0, 70.0], vec![]), "one undo step per move");
// Bad params are refused and leave the guides alone.
assert!(s.execute("view.moveGuide", json!({"orientation": "horizontal", "index": 0, "position": 5, "toOrientation": "diagonal"})).is_err());
assert!(s.execute("view.moveGuide", json!({"orientation": "horizontal", "index": 0, "position": 5, "toOrientation": 1})).is_err());
assert!(s.execute("view.newGuide", json!({"orientation": "horizontal", "position": 5, "toOrientation": "vertical"})).is_err());
assert!(s.execute("view.moveGuide", json!({"orientation": "horizontal", "index": 9, "position": 5, "toOrientation": "vertical"})).is_err());
let g = &s.active().unwrap().doc.guides;
assert_eq!((g.horizontal.clone(), g.vertical.clone()), (vec![30.0, 70.0], vec![]));
}
/// #2955: the rulers' zero point is set and reset by command, undoable, and bad params are
/// refused without touching the document.
#[test]
+1 -1
View File
@@ -341,7 +341,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Layers to Files…",
&["File", "Export"],
None,
r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {files}"##,
r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"trim":bool=false,"quality":0..12?,"jpegFormat":"baseline|optimized|progressive"?,"tiffLayers":bool=false,"tiffCompression":"none|lzw|zip"?,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"ddsCompression":"none|bc1|bc2|bc3"="none" (DDS: uncompressed, or block compression; BC1 keeps alpha on or off),"ddsMipmaps":bool=false (DDS: also write the full mip chain)} → {files}"##,
native_doc,
layers_to_files
),
+19
View File
@@ -44,6 +44,7 @@ pub struct LayerExportReport {
pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<LayerExportReport> {
let cmd = "file.export.layersToFiles";
let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase();
let trim = p.get("trim").and_then(Value::as_bool).unwrap_or(false);
let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true);
// Callers outside `Session::execute` (headless workspace exports) carry the session's gate
// into `encode`, so a PSD embeds no file a linked smart object names.
@@ -72,6 +73,24 @@ pub fn export_layers_report_with(s: &Session, p: &Value, mut write: impl FnMut(&
only.visible = true;
only.clipped = false;
one.layers = vec![only];
if trim {
let mut bounds = Rect::EMPTY;
let _ = photocraft_compose::render_bands(&one, one.bounds(), 0, |band| -> std::result::Result<(), ()> {
let width = band.rect.width() as usize;
for (i, rgba) in band.px.iter().enumerate() {
if rgba[3] > 0.0 && width > 0 {
let x = band.rect.x0 + (i % width) as i32;
let y = band.rect.y0 + (i / width) as i32;
bounds = bounds.union(&Rect::new(x, y, x + 1, y + 1));
}
}
Ok(())
});
if !bounds.is_empty() && bounds != one.bounds() {
crate::image_cmds::crop_doc(&mut one, cmd, bounds, true, [0.0; 4])?;
}
}
let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), index, sanitize(&l.name));
let (bytes, _warnings) = encode(&one, &name, save)?;
write(&name, &bytes)?;
+55
View File
@@ -1159,6 +1159,61 @@ fn layers_to_files_writes_each_visible_layer() {
assert_eq!(r["files"].as_array().unwrap().len(), 3);
}
#[test]
fn layers_to_files_can_trim_transparent_margins_without_changing_the_document() {
let dir = tmp("layers-trim");
for depth in [8, 16, 32] {
let mut s = session(12, 11, depth);
s.execute("layer.new.layer", json!({"name": "Ink"})).unwrap();
s.execute("select.rect", json!({"x": 3, "y": 2, "width": 4, "height": 5})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
let before = doc(&s).clone();
let regular = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": format!("full-{depth}"), "format": "png"})).unwrap();
let full = std::fs::read(regular["files"][0].as_str().unwrap()).unwrap();
let full = photocraft_io::import("full.png", &full).unwrap().document;
assert_eq!((full.size.width, full.size.height), (12, 11), "default is unchanged");
let trimmed = s.execute("file.export.layersToFiles", json!({"dir": dir, "prefix": format!("trim-{depth}"), "format": "png", "trim": true})).unwrap();
let image = std::fs::read(trimmed["files"][0].as_str().unwrap()).unwrap();
let cropped = photocraft_io::import("trim.png", &image).unwrap().document;
assert_eq!((cropped.size.width, cropped.size.height), (4, 5), "depth {depth}");
let px = photocraft_compose::render(&cropped, Rect::new(0, 0, 1, 1)).px[0];
assert!(px[0] > 0.95 && px[1] < 0.05 && px[2] < 0.05, "depth {depth}: {px:?}");
assert_eq!(*doc(&s), before, "export must not crop the original document");
}
}
#[test]
fn streamed_layer_export_respects_trim() {
let mut s = session(12, 11, 8);
s.execute("layer.new.layer", json!({"name": "Ink"})).unwrap();
s.execute("select.rect", json!({"x": 3, "y": 2, "width": 4, "height": 5})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
let mut encoded = Vec::new();
let names = super::export_layers_with(&s, &json!({"trim": true, "format": "png"}), |name, bytes| {
encoded.push((name.to_string(), bytes.to_vec()));
Ok(())
})
.unwrap();
assert_eq!(names.len(), encoded.len());
let cropped = photocraft_io::import(&encoded[0].0, &encoded[0].1).unwrap().document;
assert_eq!((cropped.size.width, cropped.size.height), (4, 5));
}
#[test]
fn trimming_an_empty_layer_keeps_a_valid_nonzero_canvas() {
let dir = tmp("layers-trim-empty");
let mut s = session(6, 7, 8);
s.execute("layer.new.layer", json!({"name": "Empty"})).unwrap();
let result = s.execute("file.export.layersToFiles", json!({"dir": dir, "format": "png", "trim": true})).unwrap();
let bytes = std::fs::read(result["files"][0].as_str().unwrap()).unwrap();
let back = photocraft_io::import("empty.png", &bytes).unwrap().document;
assert_eq!((back.size.width, back.size.height), (6, 7));
}
#[test]
fn load_files_into_stack_and_image_processor_and_batch() {
let dir = tmp("batch");
+30 -1
View File
@@ -163,6 +163,10 @@ fn new_snapshot(s: &mut Session, p: &Value) -> Result<Value> {
let name = name.unwrap_or_else(|| next_snapshot_name(st));
let id = st.next_snapshot;
st.next_snapshot = id.saturating_add(1);
// An action's New Snapshot with "replace existing": a snapshot of that name gives way.
if p.get("replaceExisting").and_then(Value::as_bool) == Some(true) {
st.snapshots.retain(|snap| snap.name != name);
}
st.snapshots.push(Snapshot { id, name: name.clone(), doc, layers });
Ok(json!({ "snapshot": id, "name": name }))
}
@@ -284,7 +288,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "New Snapshot",
menu: &[],
shortcut: None,
params: r##"{"name":str?="Snapshot N","from":"fullDocument"|"mergedLayers"|"currentLayer"="fullDocument"} → {snapshot,name}: a named copy of the document for the session (not a history state)"##,
params: r##"{"name":str?="Snapshot N","from":"fullDocument"|"mergedLayers"|"currentLayer"="fullDocument","replaceExisting":bool=false (drop snapshots of the same name first)} → {snapshot,name}: a named copy of the document for the session (not a history state)"##,
enabled: has_doc,
run: new_snapshot,
journal: true,
@@ -442,6 +446,31 @@ mod tests {
assert_eq!(snapshots(&s), json!([first, {"id": 1, "name": "Snapshot 1"}, {"id": 2, "name": "Before green"}]));
}
#[test]
fn replace_existing_snapshot_and_clear_history() {
let mut s = session();
s.execute("history.newSnapshot", json!({"name": "Result"})).unwrap();
s.execute("history.newSnapshot", json!({"name": "Other"})).unwrap();
s.execute("history.newSnapshot", json!({"name": "Result", "replaceExisting": true})).unwrap();
let opening = json!({"id": 0, "name": s.active().unwrap().doc.name});
assert_eq!(snapshots(&s), json!([opening, {"id": 2, "name": "Other"}, {"id": 3, "name": "Result"}]));
let kept_snapshots = snapshots(&s);
let history_before = entries(&s);
let pixel = top_pixel(&s);
s.execute("history.clear", json!({})).unwrap();
assert_eq!(entries(&s).len(), 1, "only the current state is left");
assert!(s.active().unwrap().history.can_undo(), "the current Clear History remains undoable");
assert_eq!(top_pixel(&s), pixel, "the document is unchanged");
assert_eq!(snapshots(&s), kept_snapshots, "opening and named snapshots stay unchanged");
// Current History menu semantics refuse a second clear; snapshots remain independent.
assert!(s.execute("history.clear", json!({})).is_err());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(entries(&s), history_before, "Undo restores the cleared states");
assert_eq!(snapshots(&s), kept_snapshots, "Undo also preserves every snapshot");
assert_eq!(top_pixel(&s), pixel);
assert!(Session::new().execute("history.clear", json!({})).is_err(), "no document");
}
#[test]
fn select_snapshot_returns_to_it_as_one_undoable_state() {
let mut s = session();
+17 -4
View File
@@ -334,10 +334,23 @@ fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
/// The flattened document, or an error when the canvas is over the compositor's render cap
/// ([`photocraft_compose::MAX_RENDER_PIXELS`]; `flatten` returns an empty buffer then).
fn flatten_whole(doc: &Document, cmd: &str) -> Result<photocraft_compose::Buffer> {
let buf = photocraft_compose::flatten(doc);
let (w, h) = (doc.size.width, doc.size.height);
if buf.px.len() as u64 != u64::from(w) * u64::from(h) {
return Err(bad(cmd, format!("the document ({w} x {h} px) is too large to flatten (limit {} pixels)", photocraft_compose::MAX_RENDER_PIXELS)));
let count = u64::from(w) * u64::from(h);
let operation = if cmd == "image.trim" {
"trim"
} else if cmd == "image.duplicate" {
"duplicate merged"
} else {
"flatten"
};
let too_large =
|| bad(cmd, format!("the document ({w} x {h} px) is too large to {operation} (flatten limit {} pixels)", photocraft_compose::MAX_RENDER_PIXELS));
if count > photocraft_compose::MAX_RENDER_PIXELS {
return Err(too_large());
}
let buf = photocraft_compose::flatten(doc);
if buf.px.len() as u64 != count {
return Err(too_large());
}
Ok(buf)
}
@@ -398,7 +411,7 @@ fn delete_cropped_pixels(layers: &mut [Layer], r: Rect) {
/// Crops the document to `r` (in current document coordinates).
/// A frame that extends past the canvas fills the Background's new area with `extension` (the
/// background colour, as Photoshop's Crop tool does); other layers get transparent pixels there.
fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extension: [f32; 4]) -> Result<()> {
pub(crate) fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extension: [f32; 4]) -> Result<()> {
// The origin moves to (0, 0); `-i32::MIN` has no i32 value (#959).
let (Some(dx), Some(dy)) = (r.x0.checked_neg(), r.y0.checked_neg()) else {
return Err(bad(cmd, format!("the crop origin ({}, {}) can't be moved to (0, 0) within the 32-bit coordinate range", r.x0, r.y0)));
+27
View File
@@ -961,3 +961,30 @@ fn duplicate_document() {
assert_eq!(doc(&s).layers.len(), 1);
assert!(doc(&s).name.ends_with("copy"));
}
#[test]
fn trim_and_duplicate_merged_on_too_large_document_fail_gracefully_without_panic() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 40000, "height": 30000, "background": "transparent"})).unwrap();
let initial_layers = doc(&s).layers.len();
let initial_bounds = doc(&s).bounds();
let initial_rev = s.active().unwrap().revision;
let initial_docs = s.documents().len();
let trim_res = s.execute("image.trim", json!({"basedOn": "topLeft"}));
assert!(trim_res.is_err(), "trim on too-large document must return error");
let err = trim_res.unwrap_err().to_string();
assert!(err.contains("too large to trim"), "error should explain document is too large: {err}");
assert_eq!(doc(&s).layers.len(), initial_layers, "layer count unchanged after failed trim");
assert_eq!(doc(&s).bounds(), initial_bounds, "canvas size unchanged after failed trim");
assert_eq!(s.active().unwrap().revision, initial_rev, "revision unchanged after failed trim");
let dup_res = s.execute("image.duplicate", json!({"mergedOnly": true}));
assert!(dup_res.is_err(), "duplicate merged on too-large document must return error");
let dup_err = dup_res.unwrap_err().to_string();
assert!(dup_err.contains("too large to duplicate merged"), "error should explain document is too large: {dup_err}");
assert_eq!(s.documents().len(), initial_docs, "document count unchanged after failed duplicate");
assert_eq!(doc(&s).layers.len(), initial_layers, "layer count unchanged after failed duplicate");
assert_eq!(doc(&s).bounds(), initial_bounds, "canvas size unchanged after failed duplicate");
assert_eq!(s.active().unwrap().revision, initial_rev, "revision unchanged after failed duplicate");
}
+4
View File
@@ -118,6 +118,10 @@ pub(crate) fn adapt_copies(s: &Session, copies: &mut Document, dest: &Document)
*surf = surf.convert(f);
});
}
// Type keeps its size in pixels at the destination's resolution, as an Image Size resolution
// change keeps it (Photoshop adapts the point size: 24 pt at 72 ppi is 5.76 pt at 300 ppi).
// Left at the source's, a copy re-laid out at the destination's on its next edit (#9560).
crate::canvas_geom::set_resolution(copies, dest.resolution_dpi);
let new_canvas = copies.bounds() != dest.bounds();
prepare_shapes(&mut copies.layers, dest, new_canvas);
Ok(())
@@ -369,3 +369,26 @@ fn a_copied_group_cannot_nest_the_destination_past_the_group_depth_limit() {
assert!(e.to_string().contains("deeper than"), "{e}");
assert_eq!(*doc(&s, 1), before, "nothing was copied");
}
/// #9560: a type layer copied between documents of different resolutions keeps its size in
/// pixels, also after its next text edit (it used to re-lay out at the destination's resolution:
/// a 24 pt line from 72 ppi grew about 4.2 times in a 300 ppi document). Photoshop adapts the
/// point size instead (24 pt at 72 ppi is 5.76 pt at 300 ppi); the pixels are the same.
#[test]
fn copied_type_keeps_its_pixel_size_across_resolutions_after_an_edit() {
for (from, to) in [(72, 300), (300, 72)] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": from})).unwrap();
let t = LayerId(s.execute("type.create", json!({"text": "H", "size": 24, "x": 128, "y": 100})).unwrap()["layer"].as_u64().unwrap());
let original = bounds(&s, 0, t);
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": to})).unwrap();
s.set_active(0);
s.execute("layer.select", json!({"layer": t.0})).unwrap();
let id = copied(&s.execute("layer.copyToDocument", json!({"document": 1})).unwrap());
assert_eq!(bounds(&s, 1, id).size(), original.size(), "{from}→{to}: the copy");
s.execute("type.edit", json!({"layer": id.0, "text": "M"})).unwrap();
s.execute("type.edit", json!({"layer": id.0, "text": "H"})).unwrap();
let after = bounds(&s, 1, id);
assert_eq!(after.size(), original.size(), "{from}→{to}: after an edit, {after:?} vs {original:?}");
}
}
+12 -4
View File
@@ -2,6 +2,10 @@ use super::enabled::check_all_unlocked;
use super::layer_params::{layer_param, mask_at_depth};
use super::{Document, EngineError, Layer, LayerContent, LayerId, LayerMask, Result, Session, Value, json, pixels};
/// Bring to Front / Send to Back of a layer already there: nothing to do, which isn't an error
/// (a Photoshop action plays it without complaint).
const ALREADY_THERE: &str = "the layer is already at the front or back";
/// Is layer `id` already as far as Arrange can take it in direction `dir` (up when positive)? True
/// at the top or bottom of its stack, or just above the Background, which stays at the bottom. The
/// Background itself is not: arranging it is refused outright.
@@ -26,7 +30,7 @@ pub(crate) fn arrange_at_end(doc: &Document, id: LayerId, dir: i32) -> bool {
pub(super) fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result<Value> {
let id = layer_param(s, p)?;
s.edit("Arrange", |doc, _| {
let edited = s.edit("Arrange", |doc, _| {
// The Background stays at the bottom: it doesn't move and nothing goes below it.
let bg = crate::background_cmds::background_id(doc);
if bg == Some(id) {
@@ -51,12 +55,16 @@ pub(super) fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result<Value> {
moved = true;
}
if !moved {
return Err(EngineError::Other("layer can't move further".into()));
let end = matches!(delta, i32::MAX | i32::MIN);
return Err(EngineError::Other(if end { ALREADY_THERE } else { "layer can't move further" }.into()));
}
crate::clip_group::settle(doc, &[id], &before, false);
Ok(())
})?;
Ok(Value::Null)
});
match edited {
Err(EngineError::Other(m)) if m == ALREADY_THERE => Ok(json!({"moved": false})),
r => r.map(|_| Value::Null),
}
}
/// Add (`Some`) or remove (`None`) a layer mask. Adding acts on every selected layer that can take
+295 -3
View File
@@ -10,7 +10,7 @@
//! type layer also has a vector mask). Knots are addressed by `subpath` and `knot` index, as
//! listed by `path.info`, and the result is `{name, path}` in the compact JSON form.
use photocraft_doc::{LayerContent, Path};
use photocraft_doc::{LayerContent, Path, PathOp};
use photocraft_geom::Point;
use photocraft_vector::edit::{self, Handle};
use serde_json::{Value, json};
@@ -48,7 +48,15 @@ fn knot(cmd: &str, p: &Value) -> Result<[usize; 2]> {
/// Applies `f` to the targeted path in one undoable step labelled `label`.
fn edit_path(s: &mut Session, cmd: &str, p: &Value, label: &str, f: impl FnOnce(&mut Path) -> std::result::Result<(), String>) -> Result<Value> {
let name = p.get("name").and_then(Value::as_str).unwrap_or("work").to_owned();
// A malformed target is an error, never a silent edit of the Work Path or the active layer.
let name = match p.get("name") {
Some(v) => v.as_str().ok_or_else(|| bad(cmd, "`name` must be a path name, \"work\" or \"layer\""))?,
None => "work",
}
.to_owned();
if name == "layer" && p.get("layer").is_some_and(|v| !v.is_u64()) {
return Err(bad(cmd, "`layer` must be a layer id"));
}
let layer = if name == "layer" { Some(layer_id(s, p)?) } else { None };
// Type on a path: its own path, the text laid along it again (#9114). A vector mask on the
// layer stays what `"layer"` edits, as with `path.info`, unless `"typePath": true`.
@@ -166,6 +174,70 @@ fn delete_anchor(s: &mut Session, p: &Value) -> Result<Value> {
Ok(r)
}
/// Merge Shape Components on a path (the Pen's and Path Selection's Path Operations menu): the
/// components' operations are baked into plain combined outlines, as for a shape layer.
fn merge_components(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "path.mergeShapeComponents";
let tol = match p.get("tolerance") {
Some(v) => v.as_f64().filter(|t| t.is_finite()).ok_or_else(|| bad(CMD, "`tolerance` must be a number"))?.clamp(0.05, 10.0),
None => 0.1,
};
let mask = p.get("name").and_then(Value::as_str) == Some("layer")
&& layer_id(s, p).ok().and_then(|id| s.active()?.doc.layer(id).map(|l| !matches!(l.content, LayerContent::Shape(_)))).unwrap_or(false);
edit_path(s, CMD, p, "Merge Shape Components", |path| {
let mut merged = crate::vector_cmds::bake_path(path, tol).map_err(|e| e.to_string())?;
// Components that cancel out cover nothing, but an empty vector mask reveals the whole
// layer (hides it when inverted): flip it so the mask still shows what it did.
if mask && merged.is_empty() && !path.is_empty() {
merged.inverted = !path.inverted;
}
*path = merged;
Ok(())
})
}
/// The Path Operations menu (or + / −) with path components selected: the components holding
/// `subpaths` take `op`, the way Photoshop changes the operation of the selected components. A
/// component's operation lives on its first subpath; the subpaths joined to it keep theirs. The
/// first component is what the others combine with and always draws as Combine
/// (`Path::effective_op`), so it keeps its operation; naming only it is refused.
fn set_operation(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "path.setOperation";
let op = p
.get("op")
.and_then(Value::as_str)
.and_then(crate::vector_cmds::op_from)
.filter(|op| *op != PathOp::Join)
.ok_or_else(|| bad(CMD, "`op` must be \"combine\", \"subtract\", \"intersect\" or \"exclude\""))?;
let subpaths: Vec<usize> = p
.get("subpaths")
.and_then(Value::as_array)
.filter(|a| !a.is_empty())
.ok_or_else(|| bad(CMD, "`subpaths` must list subpath indices"))?
.iter()
.map(|v| v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(CMD, "each subpath is an index")))
.collect::<Result<_>>()?;
edit_path(s, CMD, p, "Path Operation", |path| {
let components = path.components();
let mut heads = Vec::with_capacity(subpaths.len());
for i in &subpaths {
let c = components.iter().find(|c| c.contains(i)).ok_or_else(|| format!("no subpath {i} (the path has {})", path.subpaths.len()))?;
if c.start != 0 {
heads.push(c.start);
}
}
if heads.is_empty() {
return Err("the first component always combines: it is the shape the others act on".into());
}
for h in heads {
if let Some(sp) = path.subpaths.get_mut(h) {
sp.op = op;
}
}
Ok(())
})
}
pub fn specs() -> Vec<CommandSpec> {
const TARGET: &str = r#""name":str|"work"|"layer"="work","layer":id? (with "layer": a shape layer's path, a vector mask, or else type on a path's Type Path),"typePath":bool=false (with "layer": the Type Path even when the type layer has a vector mask)"#;
let spec = |id: &'static str, label: &'static str, params: String, run: fn(&mut Session, &Value) -> Result<Value>| CommandSpec {
@@ -210,12 +282,24 @@ pub fn specs() -> Vec<CommandSpec> {
r#""subpath":i,"knot":j (its neighbours are joined, keeping their handles; a subpath's last anchor removes the subpath; the path's only anchor is refused) (the result also has subpathRemoved:bool)"#.into(),
delete_anchor,
),
spec(
"path.mergeShapeComponents",
"Merge Shape Components",
r#""tolerance":px=0.1 (bakes the components' path operations into plain combined outlines, traced from the exact coverage)"#.into(),
merge_components,
),
spec(
"path.setOperation",
"Path Operation",
r#""subpaths":[i,…],"op":"combine|subtract|intersect|exclude" (the shape components holding these subpaths take the operation; the first component always combines and keeps its own; the Path Operations menu with components selected)"#.into(),
set_operation,
),
]
}
#[cfg(test)]
mod tests {
use photocraft_doc::{LayerId, ShapeLayer};
use photocraft_doc::{LayerId, PathOp, ShapeLayer};
use serde_json::json;
use crate::Session;
@@ -413,6 +497,214 @@ mod tests {
assert_eq!(s.active().unwrap().history.entries().len(), steps);
}
/// Coverage of `path` at the pixel `(x, y)`.
fn covered(path: &photocraft_doc::Path, x: i32, y: i32) -> f32 {
photocraft_vector::fill_rasterizer(path, photocraft_vector::DEFAULT_TOLERANCE).render(photocraft_geom::Rect::from_xywh(x, y, 1, 1))[0]
}
/// A 10..60 square with a front 25..45 square cut out of it (Subtract Front Shape), then
/// Merge Shape Components: plain combined outlines covering the same area, in one step.
#[test]
fn merge_shape_components_bakes_a_paths_operations() {
let mut s = session();
s.execute("path.set", json!({"path": square()})).unwrap();
let hole = json!({"subpaths": [{"closed": true, "knots": [[25, 25], [45, 25], [45, 45], [25, 45]]}]});
s.execute("path.set", json!({"path": hole, "op": "subtract"})).unwrap();
let before = work(&s);
let r = s.execute("path.mergeShapeComponents", json!({})).unwrap();
assert_eq!(r["name"], json!("work"));
assert_eq!(s.active().unwrap().history.undo_label(), Some("Merge Shape Components"));
let merged = work(&s);
assert_eq!(merged.subpaths.len(), 2, "the outline and its hole");
for (x, y) in [(15, 15), (35, 35), (55, 50), (5, 5)] {
assert!((covered(&merged, x, y) - covered(&before, x, y)).abs() < 0.01, "({x}, {y})");
}
assert_eq!((covered(&merged, 35, 35), covered(&merged, 15, 15)), (0.0, 1.0), "the hole stays a hole");
assert!(s.undo());
assert_eq!(work(&s), before);
// A saved path and a vector mask merge the same way; an inverted mask stays inverted.
s.execute("path.set", json!({"name": "Mug", "path": square()})).unwrap();
s.execute("path.mergeShapeComponents", json!({"name": "Mug"})).unwrap();
let id = s.active().unwrap().active_layer.unwrap().0;
s.execute("layer.vectorMask.add", json!({"layer": id, "path": square()})).unwrap();
s.execute("layer.vectorMask.edit", json!({"layer": id, "path": {"subpaths": square()["subpaths"], "inverted": true}})).unwrap();
s.execute("path.mergeShapeComponents", json!({"name": "layer", "layer": id})).unwrap();
let vm = s.active().unwrap().doc.layer(LayerId(id)).unwrap().vector_mask.clone().unwrap();
assert!(vm.path.inverted && !vm.path.subpaths.is_empty());
}
#[test]
fn merge_shape_components_on_a_shape_layer_rerenders_it() {
let mut s = session();
let id = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 50, 50], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
s.execute("shape.create", json!({"kind": "rect", "rect": [25, 25, 20, 20], "addTo": id, "op": "subtract"})).unwrap();
s.execute("path.mergeShapeComponents", json!({"name": "layer", "layer": id})).unwrap();
let st = s.active().unwrap();
let photocraft_doc::LayerContent::Shape(ShapeLayer { path, cache, .. }) = &st.doc.layer(LayerId(id)).unwrap().content else { panic!("shape") };
assert_eq!(covered(path, 35, 35), 0.0);
assert!(cache.is_some());
}
/// Mask components that cancel out (a square minus itself) cover nothing. Merged, the mask
/// must still hide (or, inverted, reveal) what it did, not turn into an empty revealing mask.
#[test]
fn merging_a_vector_mask_to_nothing_keeps_what_it_shows() {
for inverted in [false, true] {
let mut s = session();
let id = s.active().unwrap().active_layer.unwrap().0;
let mut cancelled = square();
cancelled["subpaths"].as_array_mut().unwrap().push(json!({"closed": true, "op": "subtract", "knots": [[10, 10], [60, 10], [60, 60], [10, 60]]}));
cancelled["inverted"] = json!(inverted);
s.execute("layer.vectorMask.add", json!({"layer": id, "path": cancelled})).unwrap();
let mask = |s: &Session| s.active().unwrap().doc.layer(LayerId(id)).unwrap().vector_mask.clone().unwrap();
let shown = |s: &Session| photocraft_vector::vector_mask_values(&mask(s), photocraft_geom::Rect::from_xywh(0, 0, 120, 100));
let before = shown(&s);
assert!(before.iter().all(|v| *v == if inverted { 1.0 } else { 0.0 }), "inverted {inverted}");
s.execute("path.mergeShapeComponents", json!({"name": "layer", "layer": id})).unwrap();
assert!(mask(&s).path.is_empty());
assert_eq!(shown(&s), before, "inverted {inverted}");
}
}
/// A hole cut with Combine by mistake becomes a hole: the component takes Subtract Front Shape,
/// in one step, on the Work Path, a shape layer (re-rendered) and a vector mask alike.
#[test]
fn set_operation_changes_existing_components() {
let mut s = session();
let hole = json!({"subpaths": [{"closed": true, "knots": [[25, 25], [45, 25], [45, 45], [25, 45]]}]});
s.execute("path.set", json!({"path": square()})).unwrap();
s.execute("path.set", json!({"path": hole, "op": "combine"})).unwrap();
assert_eq!(covered(&work(&s), 35, 35), 1.0);
s.execute("path.setOperation", json!({"subpaths": [1], "op": "subtract"})).unwrap();
assert_eq!(s.active().unwrap().history.undo_label(), Some("Path Operation"));
assert_eq!(covered(&work(&s), 35, 35), 0.0);
assert_eq!(work(&s).subpaths[1].op, PathOp::Subtract);
assert!(s.undo());
assert_eq!(work(&s).subpaths[1].op, PathOp::Combine);
let id = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 50, 50], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
s.execute("shape.create", json!({"kind": "rect", "rect": [25, 25, 20, 20], "addTo": id, "op": "combine"})).unwrap();
s.execute("path.setOperation", json!({"name": "layer", "layer": id, "subpaths": [1], "op": "subtract"})).unwrap();
let st = s.active().unwrap();
let photocraft_doc::LayerContent::Shape(ShapeLayer { path, cache, .. }) = &st.doc.layer(LayerId(id)).unwrap().content else { panic!("shape") };
assert_eq!(covered(path, 35, 35), 0.0);
assert!(cache.is_some());
}
/// A component's operation is its first subpath's: naming a joined subpath changes the head,
/// and the joined subpaths stay joined.
#[test]
fn set_operation_on_a_joined_subpath_changes_its_component() {
let mut s = session();
let mut p = square();
let subs = p["subpaths"].as_array_mut().unwrap();
subs.push(json!({"closed": true, "op": "combine", "knots": [[70, 10], [110, 10], [110, 50], [70, 50]]}));
subs.push(json!({"closed": true, "op": "join", "knots": [[80, 20], [80, 40], [100, 40], [100, 20]]}));
s.execute("path.set", json!({"path": p})).unwrap();
s.execute("path.setOperation", json!({"subpaths": [2], "op": "exclude"})).unwrap();
let ops: Vec<_> = work(&s).subpaths.iter().map(|sp| sp.op).collect();
assert_eq!(ops, [PathOp::Combine, PathOp::Exclude, PathOp::Join]);
}
/// The first component always draws as Combine: naming it with others changes only the
/// others, and naming only it is refused without a History step (feedback on #7648).
#[test]
fn set_operation_leaves_the_first_component_combining() {
let mut s = session();
let hole = json!({"subpaths": [{"closed": true, "knots": [[25, 25], [45, 25], [45, 45], [25, 45]]}]});
s.execute("path.set", json!({"path": square()})).unwrap();
s.execute("path.set", json!({"path": hole, "op": "combine"})).unwrap();
let steps = s.active().unwrap().history.entries().len();
let e = s.execute("path.setOperation", json!({"subpaths": [0], "op": "subtract"})).unwrap_err().to_string();
assert!(e.contains("first component"), "{e}");
assert_eq!(s.active().unwrap().history.entries().len(), steps);
s.execute("path.setOperation", json!({"subpaths": [0, 1], "op": "subtract"})).unwrap();
let ops: Vec<_> = work(&s).subpaths.iter().map(|sp| sp.op).collect();
assert_eq!(ops, [PathOp::Combine, PathOp::Subtract]);
assert_eq!((covered(&work(&s), 35, 35), covered(&work(&s), 15, 15)), (0.0, 1.0));
}
#[test]
fn bad_set_operation_params_change_nothing() {
let mut s = session();
s.execute("path.set", json!({"path": square()})).unwrap();
let steps = s.active().unwrap().history.entries().len();
for (p, msg) in [
(json!({"subpaths": [0]}), "`op`"),
(json!({"subpaths": [0], "op": "join"}), "`op`"),
(json!({"subpaths": [0], "op": 3}), "`op`"),
(json!({"op": "subtract"}), "`subpaths`"),
(json!({"subpaths": [], "op": "subtract"}), "`subpaths`"),
(json!({"subpaths": [-1], "op": "subtract"}), "index"),
(json!({"subpaths": [7], "op": "subtract"}), "no subpath 7"),
(json!({"subpaths": [0], "op": "subtract", "name": "Nope"}), ""),
(json!({"subpaths": [0], "op": "subtract", "name": "layer", "layer": "x"}), "`layer`"),
] {
let e = s.execute("path.setOperation", p.clone()).unwrap_err().to_string();
assert!(e.contains(msg), "{p}: {e}");
}
assert_eq!(s.active().unwrap().history.entries().len(), steps);
assert_eq!(work(&s).subpaths[0].op, PathOp::Combine);
}
/// Rejected merges name the problem and leave every path as it was, including the one a
/// malformed target would otherwise have fallen back to (the Work Path, the active layer).
#[test]
fn bad_merge_params_change_nothing() {
let mut s = session();
s.execute("path.set", json!({"path": square()})).unwrap();
let hole = json!({"subpaths": [{"closed": true, "knots": [[25, 25], [45, 25], [45, 45], [25, 45]]}]});
s.execute("path.set", json!({"path": hole, "op": "subtract"})).unwrap();
let id = s.active().unwrap().active_layer.unwrap().0;
s.execute("layer.vectorMask.add", json!({"layer": id, "path": square()})).unwrap();
let state = |s: &Session| {
let d = s.active().unwrap();
(d.doc.work_path.clone(), d.doc.paths.clone(), d.doc.layer(LayerId(id)).unwrap().vector_mask.clone(), d.history.entries().len())
};
let before = state(&s);
for (p, msg) in [
(json!({"tolerance": "fine"}), "tolerance"),
(json!({"name": 5}), "`name`"),
(json!({"name": "nope"}), "no path named"),
(json!({"name": "layer", "layer": "x"}), "`layer`"),
(json!({"name": "layer", "layer": 999}), "999"),
] {
let e = s.execute("path.mergeShapeComponents", p.clone()).unwrap_err().to_string();
assert!(e.contains(msg), "{p}: {e}");
assert!(state(&s) == before, "{p}: nothing changed");
}
}
/// Tracing an open subpath's fill would close it, or drop a straight one or a lone
/// anchor (a dot under a round or square cap) entirely: the merge
/// is refused and the path, its stroked layer and the history stay as they were.
#[test]
fn merge_refuses_open_subpaths() {
for open in [json!({"closed": false, "knots": [[10, 10], [60, 60]]}), json!({"closed": false, "knots": [[70, 70]]})] {
let mut s = session();
let mut mixed = square();
mixed["subpaths"].as_array_mut().unwrap().push(open);
s.execute("path.set", json!({"path": mixed})).unwrap();
let id = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 50, 50]})).unwrap()["layer"].as_u64().unwrap();
s.execute("shape.edit", json!({"layer": id, "path": mixed})).unwrap();
let state = |s: &Session| {
let d = s.active().unwrap();
let photocraft_doc::LayerContent::Shape(sh) = &d.doc.layer(LayerId(id)).unwrap().content else { panic!("shape") };
(d.doc.work_path.clone(), sh.path.clone(), d.history.entries().len())
};
let before = state(&s);
for (cmd, p) in [
("path.mergeShapeComponents", json!({})),
("path.mergeShapeComponents", json!({"name": "layer", "layer": id})),
("layer.combineShapes.mergeShapeComponents", json!({"layer": id})),
] {
let e = s.execute(cmd, p.clone()).unwrap_err().to_string();
assert!(e.contains("open subpaths"), "{cmd} {p}: {e}");
assert!(state(&s) == before, "{cmd} {p}: nothing changed");
}
}
}
#[test]
fn bad_params_fail_without_an_edit() {
let mut s = session();
+58 -17
View File
@@ -25,17 +25,21 @@ pub struct PatternLibrary {
/// Bumped when a pattern's pixels are replaced under the same id (an imported `.pat` whose
/// id is already in the library), which its id, name and size don't show.
pub pixels_rev: u64,
/// Saved patterns held as a small preview until used, by id: their full size. The preset
/// store reads the pixels ([`full`]); everything that paints goes through [`resolve`].
pub stored: std::collections::HashMap<String, (u32, u32)>,
}
impl Default for PatternLibrary {
fn default() -> Self {
PatternLibrary { items: builtin(), pixels_rev: 0 }
PatternLibrary { items: builtin(), pixels_rev: 0, stored: Default::default() }
}
}
impl PatternLibrary {
/// Add `pat`, or replace the pattern with its id (marking the pixels changed).
pub fn upsert(&mut self, pat: Pattern) {
self.stored.remove(&pat.id);
match self.items.iter_mut().find(|q| q.id == pat.id) {
Some(q) => {
*q = pat;
@@ -132,25 +136,59 @@ fn has_pattern(s: &Session) -> std::result::Result<(), String> {
if s.patterns.items.is_empty() && s.active().is_none_or(|d| d.doc.patterns.is_empty()) { Err("no patterns".into()) } else { Ok(()) }
}
/// Finds a pattern by id or name: the active document's first, then the library. An empty key
/// picks the first library pattern (Photoshop's default).
pub fn resolve(s: &Session, key: &str) -> Option<Pattern> {
if key.is_empty() {
// The Patterns panel's selection, else the first library pattern.
return crate::presets::patterns::current(s).or(s.patterns.items.first()).cloned();
/// `p` with its full pixels: a saved pattern held as a preview is read from the preset store.
pub fn full(s: &Session, p: &Pattern) -> std::result::Result<Pattern, String> {
if !s.patterns.stored.contains_key(&p.id) {
return Ok(p.clone());
}
if let Some(d) = s.active()
let st = s.preset_store.as_ref().ok_or_else(|| format!("pattern \"{}\" is not loaded: no preset store", p.display_name()))?;
let mut f = st.load_pattern(&p.id).map_err(|e| format!("pattern \"{}\" could not be loaded: {e}", p.display_name()))?;
f.name = p.name.clone();
Ok(f)
}
/// The library pattern `key` names (the panel's selection or the first for an empty key).
fn library_pattern<'a>(s: &'a Session, key: &str) -> Option<&'a Pattern> {
if key.is_empty() {
return crate::presets::patterns::current(s).or(s.patterns.items.first());
}
photocraft_doc::pattern::find(&s.patterns.items, key, key)
}
/// The id of the pattern [`resolve`] finds, without reading a saved pattern's pixels: for checks
/// and lookups that don't paint.
pub fn resolve_id(s: &Session, key: &str) -> Option<String> {
if !key.is_empty()
&& let Some(d) = s.active()
&& let Some(p) = photocraft_doc::pattern::find(&d.doc.patterns, key, key)
{
return Some(p.id.clone());
}
library_pattern(s, key).map(|p| p.id.clone())
}
/// Finds a pattern by id or name: the active document's first, then the library. An empty key
/// picks the first library pattern (Photoshop's default). `None` also when a saved pattern's
/// pixels can't be read (see [`resolve_param`] for the reason).
pub fn resolve(s: &Session, key: &str) -> Option<Pattern> {
if !key.is_empty()
&& let Some(d) = s.active()
&& let Some(p) = photocraft_doc::pattern::find(&d.doc.patterns, key, key)
{
return Some(p.clone());
}
photocraft_doc::pattern::find(&s.patterns.items, key, key).cloned()
full(s, library_pattern(s, key)?).ok()
}
pub(crate) fn resolve_param(s: &Session, cmd: &str, p: &Value) -> Result<Pattern> {
let key = p.get("pattern").and_then(Value::as_str).unwrap_or("");
let pat = resolve(s, key).ok_or_else(|| bad(cmd, format!("no pattern \"{key}\" (see pattern.list)")))?;
paintable(cmd, pat)
if let Some(pat) = resolve(s, key) {
return paintable(cmd, pat);
}
match library_pattern(s, key).map(|lp| full(s, lp)) {
Some(Err(e)) => Err(bad(cmd, e)),
_ => Err(bad(cmd, format!("no pattern \"{key}\" (see pattern.list)"))),
}
}
/// `pat`, or the reason it would paint nothing: a pattern too large for the compositor renders as
@@ -185,10 +223,10 @@ pub fn resolve_effect(s: &Session, fx: photocraft_doc::Effect) -> Result<(photoc
}
}
fn info(p: &Pattern, in_doc: bool, in_library: bool) -> Value {
fn info(p: &Pattern, size: (u32, u32), in_doc: bool, in_library: bool) -> Value {
let f = p.surface.format();
json!({
"id": p.id, "name": p.display_name(), "storedName": p.name, "width": p.width, "height": p.height,
"id": p.id, "name": p.display_name(), "storedName": p.name, "width": size.0, "height": size.1,
"mode": format!("{:?}", f.mode), "depth": format!("{:?}", f.sample), "alpha": f.alpha,
"inDocument": in_doc, "inLibrary": in_library,
"paintable": p.is_paintable(), "paintProblem": p.paint_problem(),
@@ -197,8 +235,9 @@ fn info(p: &Pattern, in_doc: bool, in_library: bool) -> Value {
fn list(s: &mut Session, _: &Value) -> Result<Value> {
let doc_pats: Vec<Pattern> = s.active().map(|d| d.doc.patterns.clone()).unwrap_or_default();
let mut out: Vec<Value> = s.patterns.items.iter().map(|p| info(p, doc_pats.iter().any(|d| d.id == p.id), true)).collect();
out.extend(doc_pats.iter().filter(|d| !s.patterns.items.iter().any(|p| p.id == d.id)).map(|p| info(p, true, false)));
let size = |p: &Pattern| s.patterns.stored.get(&p.id).copied().unwrap_or((p.width, p.height));
let mut out: Vec<Value> = s.patterns.items.iter().map(|p| info(p, size(p), doc_pats.iter().any(|d| d.id == p.id), true)).collect();
out.extend(doc_pats.iter().filter(|d| !s.patterns.items.iter().any(|p| p.id == d.id)).map(|p| info(p, (p.width, p.height), true, false)));
Ok(Value::Array(out))
}
@@ -299,7 +338,7 @@ fn define(s: &mut Session, p: &Value) -> Result<Value> {
};
let pat = Pattern::new(name, surf, r.width(), r.height());
let id = pat.id.clone();
let out = info(&pat, false, true);
let out = info(&pat, (pat.width, pat.height), false, true);
if !s.patterns.items.iter().any(|q| q.id == id) {
s.patterns.items.push(pat);
}
@@ -334,6 +373,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = library_index(s, "pattern.delete", p)?;
let pat = s.patterns.items.remove(i);
s.patterns.stored.remove(&pat.id);
Ok(json!({"deleted": pat.id}))
}
@@ -349,6 +389,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let mut ids = Vec::new();
for pat in pats {
ids.push(pat.id.clone());
s.patterns.stored.remove(&pat.id);
s.patterns.upsert(pat);
}
Ok(json!({"imported": ids, "count": ids.len()}))
@@ -361,7 +402,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
Some(keys) => {
keys.iter().filter_map(Value::as_str).map(|k| resolve(s, k).ok_or_else(|| bad(cmd, format!("no pattern \"{k}\"")))).collect::<Result<_>>()?
}
None => s.patterns.items.clone(),
None => s.patterns.items.iter().map(|p| full(s, p).map_err(|e| bad(cmd, e))).collect::<Result<_>>()?,
};
let bytes = photocraft_io::pattern_map::write_pat(&pats).map_err(EngineError::Other)?;
crate::file_cmds::write_file(path, &bytes)?;
Loaded 100 of 380 files, more files were not shown because too many files have changed in this diff. Show more