Files
photocraft/crates/engine/src/file_cmds.rs
T

1930 lines
90 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File-menu commands beyond New/Close: Close All / Others, Revert, Save a Copy, Open As, Place
//! Embedded / Linked, File Info, the Automate and Scripts items (Fit Image, Conditional Mode
//! Change, Batch, Image Processor, Load Files into Stack, Flatten All Layer Effects / Masks),
//! Export › Layers to Files and Color Lookup Tables, plus the document-level guide layouts of the
//! View menu (New Guide Layout, New Guides From Shape, Clear Canvas Guides).
//!
//! Commands that touch the file system take explicit paths (the UI asks for them with its own
//! pickers) and are unavailable on the web, where there is no file system; their byte-level
//! cores ([`place_bytes`], [`open_bytes_as`]) work everywhere.
use std::sync::Arc;
use photocraft_algo::resample::{Resample, resize_surface, translate_surface};
use photocraft_color::{ColorMode, PixelFormat};
use photocraft_doc::{Affine, Document, Layer, LayerContent, LayerId, SmartObject, SmartSource};
use photocraft_geom::Rect;
use photocraft_raster::{Surface, from_rgba_into};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, layer_param};
use crate::{EngineError, Result, Session};
mod batch_naming;
// ---------- predicates ----------
fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
/// The file system is reachable (not on the web).
pub(crate) fn native(_: &Session) -> std::result::Result<(), String> {
if cfg!(target_arch = "wasm32") { Err("not available on the web (no file system)".into()) } else { Ok(()) }
}
pub(crate) fn native_doc(s: &Session) -> std::result::Result<(), String> {
native(s)?;
has_doc(s)
}
fn can_revert(s: &Session) -> std::result::Result<(), String> {
native(s)?;
let d = s.active().ok_or("no document open")?;
let path = d.path.as_deref().ok_or("the document has never been saved")?;
if !is_file(path) {
return Err(format!("{path} is not a file on disk"));
}
Ok(())
}
fn has_adjustments(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
if d.doc.layers.iter().any(|l| l.visible && matches!(l.content, LayerContent::Adjustment(_))) {
Ok(())
} else {
Err("the document has no visible adjustment layers to export".into())
}
}
fn has_shape(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
match d.active_layer.and_then(|id| d.doc.layer(id)).map(|l| &l.content) {
Some(LayerContent::Shape(_)) => Ok(()),
_ => Err("the active layer is not a shape layer".into()),
}
}
// ---------- file system (native only) ----------
#[cfg(not(target_arch = "wasm32"))]
fn is_file(path: &str) -> bool {
std::path::Path::new(path).is_file()
}
#[cfg(target_arch = "wasm32")]
fn is_file(_path: &str) -> bool {
false
}
#[cfg(not(target_arch = "wasm32"))]
/// Every file the engine opens is read here: in bounded reads, with a clear error when it does
/// not fit in memory (see [`photocraft_format::read`]).
pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
photocraft_format::read_file(std::path::Path::new(path)).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
Err(EngineError::Other(format!("cannot read {path}: no file system on the web")))
}
#[cfg(not(target_arch = "wasm32"))]
/// Every file the engine writes goes through here: crash-safe (temp file + fsync + rename, see
/// [`photocraft_format::atomic`]), so a failed save never destroys the previous file.
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
photocraft_format::atomic_write(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(e.to_string()))
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
Err(EngineError::Other(format!("cannot write {path}: no file system on the web")))
}
/// Files of a folder that look like images we can open, sorted by name.
#[cfg(not(target_arch = "wasm32"))]
pub fn list_images(dir: &str) -> Result<Vec<String>> {
let rd = std::fs::read_dir(dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?;
let mut out: Vec<String> = rd
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file() && openable_name(&p.to_string_lossy()))
.map(|p| p.to_string_lossy().into_owned())
.collect();
out.sort();
Ok(out)
}
#[cfg(target_arch = "wasm32")]
pub fn list_images(dir: &str) -> Result<Vec<String>> {
Err(EngineError::Other(format!("cannot list {dir}: no file system on the web")))
}
/// Whether a folder entry is one the batch commands open: an `OPENABLE` extension, or GIMP's
/// gzipped `.xcf.gz`, whose last extension alone says nothing.
#[cfg(not(target_arch = "wasm32"))]
fn openable_name(path: &str) -> bool {
let lower = file_name(path).to_ascii_lowercase();
lower.ends_with(".xcf.gz") || lower.rsplit_once('.').is_some_and(|(_, ext)| OPENABLE.contains(&ext))
}
/// Extensions the batch commands pick up from a folder.
#[cfg(not(target_arch = "wasm32"))]
const OPENABLE: &[&str] = &[
"pdn", "ora", "xcf", "xcfgz", "kra", "krz", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi",
"ico", "pnm", "ppm", "pgm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign",
"afphoto", "afpub",
];
/// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone
/// Background layer). The UI asks before such a save when the "ask before saving layered TIFF"
/// preference is on; see `photocraft_io::tiff_layers`.
pub fn tiff_would_write_layers(doc: &Document) -> bool {
photocraft_io::tiff_layers::would_write_layers(doc)
}
pub(crate) fn file_name(path: &str) -> String {
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
}
impl crate::DocState {
/// Complete a successful document save. File identity is not a history step; callers must
/// finish the write first and must not call this for a copy or export.
pub fn saved_to(&mut self, path: String) {
let name = file_name(&path);
if self.doc.name != name {
Arc::make_mut(&mut self.doc).name = name;
}
self.path = Some(path);
self.saved_revision = self.revision;
}
}
/// The lower-case extension of the file name in `path` (none for `.hidden` or `name`).
pub fn extension(path: &str) -> Option<String> {
file_name(path).rsplit_once('.').filter(|(base, ext)| !base.is_empty() && !ext.is_empty()).map(|(_, ext)| ext.to_ascii_lowercase())
}
/// Whether a save without a new path may write back to `path` as far as its format goes: only
/// layered files (PSD, PSB, OpenRaster, .pcraft). A flat file goes through Save As instead, so it
/// is never flattened over the original. Whether this document fits is [`holds_at`].
pub fn saves_in_place(path: &str) -> bool {
extension(path).is_some_and(|ext| layered_extension(&ext))
}
/// Whether the lower-case extension `ext` names a layered document format (PSD, PSB, OpenRaster,
/// .pcraft). A save to one becomes the document's file; any other format is a copy for automation
/// saves, desktop and headless alike (#1547, #2579).
pub fn layered_extension(ext: &str) -> bool {
matches!(ext, "psd" | "psb" | "pcraft" | "ora")
}
/// Whether a file in format `ext` (lower-case) holds the whole of `doc`, so it can be the
/// document's own file: PSD, PSB and .pcraft always; OpenRaster only when it drops nothing (no
/// adjustment layers, styles, masks…, [`photocraft_io::ora_losses`]). A save to a format that
/// doesn't hold the document is a copy, and File › Save goes through Save As, so an editable
/// original is never overwritten with less than it had (#3806).
pub fn holds(doc: &photocraft_doc::Document, ext: &str) -> bool {
match ext {
"psd" | "psb" | "pcraft" => true,
"ora" => photocraft_io::ora_losses(doc).is_empty(),
_ => false,
}
}
/// [`holds`] for the file at `path`.
pub fn holds_at(doc: &photocraft_doc::Document, path: &str) -> bool {
extension(path).is_some_and(|ext| holds(doc, &ext))
}
/// What a save of `doc` to `path` would leave out when its format can't hold it: OpenRaster's
/// [`photocraft_io::ora_losses`]; empty for other formats.
pub fn losses_at(doc: &photocraft_doc::Document, path: &str) -> Vec<String> {
match extension(path).as_deref() {
Some("ora") => photocraft_io::ora_losses(doc),
_ => Vec::new(),
}
}
/// Whether `path` names a document template (.psdt). A template opens as a new untitled document
/// without its path, so a save never writes over the template.
pub fn is_template(path: &str) -> bool {
extension(path).as_deref() == Some("psdt")
}
/// The first "`base`-N" that isn't `taken`.
pub fn untitled_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
(1..).map(|i| format!("{base}-{i}")).find(|n| !taken(n)).unwrap_or_default()
}
/// The name a document opened from `path` gets when `path` is a template: the first "Untitled-N"
/// no open document has.
pub fn template_name(s: &Session, path: &str) -> Option<String> {
is_template(path).then(|| untitled_name("Untitled", |n| s.documents().iter().any(|d| d.doc.name == n)))
}
pub(crate) fn stem(path: &str) -> String {
let n = file_name(path);
match n.rfind('.') {
Some(i) if i > 0 => n[..i].to_string(),
_ => n,
}
}
/// Whether `a` and `b` name the same file: the same text, or, when both exist, the same file on
/// disk (a hard link, a different case on a case-insensitive volume, `./` or `..` in one of them).
pub fn same_file(a: &str, b: &str) -> bool {
if a == b {
return true;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
return x.dev() == y.dev() && x.ino() == y.ino();
}
}
#[cfg(windows)]
{
// The volume serial number and file index of each, as unix compares dev and inode, so a
// hard link (another name for the file) is the same file.
if let Ok(same) = same_file::is_same_file(a, b) {
return same;
}
// Should a file not open: NTFS and FAT compare names without case.
if let (Ok(x), Ok(y)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
return x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase();
}
}
false
}
/// A path as command results report it: `/` between components, whatever the platform.
///
/// `join` already builds with `/`, but a directory from the caller or from `std::path::absolute`
/// carries the native separator, which would leave a reported path mixed. The control channel
/// takes `/` and rejects alternate separators (`AuthorizedWorkspace` in `crates/automation`), so
/// a result spelt this way can be handed straight back to it. Verbatim and UNC prefixes keep
/// theirs: Win32 does not accept `/` after a leading `\\`.
pub(crate) fn forward_slashes(path: String) -> String {
if path.starts_with(r"\\") { path } else { path.replace('\\', "/") }
}
pub(crate) fn join(dir: &str, name: &str) -> String {
if dir.is_empty() {
name.to_string()
} else if dir.ends_with('/') || dir.ends_with('\\') {
format!("{dir}{name}")
} else {
format!("{dir}/{name}")
}
}
/// A file-name-safe version of a layer name.
pub(crate) fn sanitize(name: &str) -> String {
let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect();
let s = s.trim().trim_matches('.').to_string();
if s.is_empty() { "layer".into() } else { s }
}
pub(crate) fn import(name: &str, bytes: &[u8]) -> Result<Document> {
crate::allocation::checkpoint("importing document")?;
let r = photocraft_io::import(name, bytes).map_err(|e| EngineError::Other(format!("{name}: {e}")))?;
// Auxiliary imports return only a document and cannot surface the preview's fidelity warning.
// Open has its own warning-preserving path; never silently place or process a thumbnail.
if r.preview_only {
return Err(EngineError::Other(format!(
"{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here"
)));
}
Ok(r.document)
}
/// What a headless save writes beyond the format: JPEG quality, TIFF layers, Targa bits, DPX
/// bits, DDS compression and mipmaps, PSD previews and the PSD merged image.
#[derive(Debug, Clone, Copy)]
pub(crate) struct SaveOpts {
/// Photoshop's 0–12 JPEG scale.
pub quality: Option<f64>,
/// TIFF: keep the layers. Off unless a command's params ask (`"tiffLayers": true`).
pub tiff_layers: bool,
/// Targa: 24 or 32 bits per pixel (`"tgaBits"`); unset, 32 with an alpha channel or
/// transparency, else 24.
pub tga_bits: Option<photocraft_io::TgaBits>,
/// DPX: 8, 10, 12 or 16 bits per sample (`"dpxBits"`); unset, 8 for an 8-bit document, else 16.
pub dpx_bits: Option<photocraft_io::DpxBits>,
/// DDS: the compression and whether to write the mip chain (`"ddsCompression"`,
/// `"ddsMipmaps"`); unset, uncompressed without mipmaps.
pub dds: photocraft_io::DdsOptions,
/// PSD/PSB: embed the thumbnail resource (Preferences › File Handling › Image Previews).
pub image_previews: bool,
/// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB
/// File Compatibility).
pub psd_maximize_compatibility: bool,
}
impl Default for SaveOpts {
fn default() -> Self {
SaveOpts {
quality: None,
tiff_layers: false,
tga_bits: None,
dpx_bits: None,
dds: photocraft_io::DdsOptions::default(),
image_previews: true,
psd_maximize_compatibility: true,
}
}
}
impl SaveOpts {
/// `quality`, `tiffLayers` and `tgaBits` from command `cmd`'s params (a `tgaBits` other than
/// 24 or 32 is an error); the image previews and the PSD merged image from the session's File
/// Handling preferences (Ask When Saving has no dialog here, so it saves them, as Photoshop's
/// Save As checkbox and its Maximize Compatibility checkbox both start on).
pub(crate) fn from_params(s: &Session, p: &Value, cmd: &str) -> Result<Self> {
let file_handling = &s.prefs().file_handling;
Ok(SaveOpts {
quality: f64_param(p, "quality"),
tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false),
tga_bits: tga_bits_param(p, cmd)?,
dpx_bits: dpx_bits_param(p, cmd)?,
dds: dds_param(p, cmd)?.unwrap_or_default(),
image_previews: file_handling.image_previews != crate::prefs::Ask::Never,
psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never,
})
}
pub(crate) fn or_quality(mut self, q: f64) -> Self {
self.quality = self.quality.or(Some(q));
self
}
}
impl From<Option<f64>> for SaveOpts {
fn from(quality: Option<f64>) -> Self {
SaveOpts { quality, ..Default::default() }
}
}
/// The Targa bits per pixel in `p` (`"tgaBits": 24 | 32`; absent or null: the automatic choice).
/// Anything else is a bad parameter of command `cmd`. Shared by every save that takes it (the
/// engine's commands, the app's Save As and control channel, the headless server).
pub fn tga_bits_param(p: &Value, cmd: &str) -> Result<Option<photocraft_io::TgaBits>> {
match p.get("tgaBits") {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_u64()
.and_then(photocraft_io::TgaBits::from_bits)
.map(Some)
.ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("`tgaBits` must be 24 or 32, got {v}") }),
}
}
/// The DPX bits per sample in `p` (`"dpxBits": 8 | 10 | 12 | 16`; absent or null: the document's
/// depth). Anything else is a bad parameter of command `cmd`.
pub fn dpx_bits_param(p: &Value, cmd: &str) -> Result<Option<photocraft_io::DpxBits>> {
match p.get("dpxBits") {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_u64()
.and_then(photocraft_io::DpxBits::from_bits)
.map(Some)
.ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("`dpxBits` must be 8, 10, 12 or 16, got {v}") }),
}
}
/// The DDS options in `p`: `"ddsCompression": "none" | "bc1" | "bc2" | "bc3"` (or the DXT names)
/// and `"ddsMipmaps": bool`. `None` when neither is given (Save As then asks); either alone
/// leaves the other at its default (uncompressed, no mipmaps). Anything else is a bad parameter
/// of command `cmd`.
pub fn dds_param(p: &Value, cmd: &str) -> Result<Option<photocraft_io::DdsOptions>> {
let bad = |msg: String| EngineError::BadParams { cmd: cmd.into(), msg };
let compression = match p.get("ddsCompression") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_str()
.and_then(photocraft_io::DdsCompression::from_name)
.ok_or_else(|| bad(format!("`ddsCompression` must be \"none\", \"bc1\", \"bc2\" or \"bc3\", got {v}")))?,
),
};
let mipmaps = match p.get("ddsMipmaps") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(format!("`ddsMipmaps` must be true or false, got {v}")))?),
};
if compression.is_none() && mipmaps.is_none() {
return Ok(None);
}
Ok(Some(photocraft_io::DdsOptions { compression: compression.unwrap_or_default(), mipmaps: mipmaps.unwrap_or(false) }))
}
/// Encodes `doc` for `path`'s extension.
pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> Result<(Vec<u8>, Vec<String>)> {
crate::allocation::checkpoint("saving document")?;
let save = save.into();
let mut opts = photocraft_io::ExportOptions {
tiff_layers: save.tiff_layers,
tga_bits: save.tga_bits,
image_previews: save.image_previews,
psd_maximize_compatibility: save.psd_maximize_compatibility,
// Linked files a PSD embeds are resolved against the document being worked on.
document_path: crate::doc_path::current(),
..Default::default()
};
// No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP
// lossy or lossless as it was), as Photoshop's Save does.
photocraft_io::apply_file_encoding(doc, &mut opts.encode);
opts.encode.dpx_bits = save.dpx_bits;
opts.encode.dds = save.dds;
if let Some(q) = save.quality {
let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8;
opts.encode.jpeg_quality = q;
// A quality on a WebP save asks for the lossy encoder; the default WebP stays lossless.
opts.encode.webp_quality = q;
opts.encode.webp_lossless = false;
}
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
pub(crate) fn save_doc(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> Result<Vec<String>> {
let (bytes, warnings) = encode(doc, path, save)?;
write_file(path, &bytes)?;
Ok(warnings)
}
pub(crate) fn str_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<&'a str> {
p.get(key).and_then(Value::as_str).filter(|v| !v.is_empty()).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("missing \"{key}\"") })
}
pub(crate) fn f64_param(p: &Value, key: &str) -> Option<f64> {
p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite())
}
/// A size parameter; zero or negative means "not set" (dialogs send 0 for empty fields).
fn size_param(p: &Value, key: &str) -> Option<f64> {
f64_param(p, key).filter(|v| *v > 0.0)
}
// ---------- pixels ----------
/// A composite buffer as a surface in `fmt` (straight RGBA → the format's model and depth).
pub(crate) fn buffer_surface(buf: &photocraft_compose::Buffer, fmt: PixelFormat) -> Surface {
let n = fmt.channels();
let mut data = vec![0.0f32; buf.px.len() * n];
for (p, out) in buf.px.iter().zip(data.chunks_exact_mut(n)) {
from_rgba_into(&fmt, *p, out);
}
let mut s = Surface::new(fmt);
if !buf.rect.is_empty() {
s.write_region(buf.rect, &data);
}
s.prune();
s
}
/// The flattened image of `doc` as a surface in `fmt`, placed at the origin.
pub(crate) fn flattened(doc: &Document, fmt: PixelFormat) -> Surface {
photocraft_compose::flatten_to_surface(doc, fmt, None)
}
// ---------- close / revert / save a copy / open as ----------
fn close_all(s: &mut Session) -> Result<Value> {
let n = s.documents().len();
while !s.documents().is_empty() {
s.close(s.documents().len() - 1);
}
Ok(json!({"closed": n}))
}
fn close_others(s: &mut Session, p: &Value) -> Result<Value> {
let keep = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?;
if keep >= s.documents().len() {
return Err(EngineError::BadParams { cmd: "file.closeOthers".into(), msg: format!("no document {keep}") });
}
let n = s.documents().len() - 1;
for i in (0..s.documents().len()).rev() {
if i != keep {
s.close(i);
}
}
s.set_active(0);
Ok(json!({"closed": n}))
}
/// File › Revert: reload the saved file as one history step (undoable, like Photoshop's
/// "Revert" history state); the document is clean afterwards.
fn revert(s: &mut Session) -> Result<Value> {
let path = s.active().and_then(|d| d.path.clone()).ok_or(EngineError::Other("the document has never been saved".into()))?;
let bytes = read_file(&path)?;
let fresh = import(&file_name(&path), &bytes)?;
s.edit("Revert", |doc, active| {
let (id, name) = (doc.id, doc.name.clone());
*doc = fresh;
// Keep the identity (colour state, views and caches are keyed by it) and the tab name.
doc.id = id;
doc.name = name;
*active = doc.top_layer();
Ok(())
})?;
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
st.saved_revision = st.revision;
Ok(json!({"path": path, "layers": st.doc.layers.len()}))
}
fn save_a_copy(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path", "file.saveACopy")?.to_string();
let d = s.active().ok_or(EngineError::NoDocument)?;
// A copy over the document's own file replaces it, and a flat or lossy format can't be
// undone, so it takes an explicit `overwrite` (#7437). The desktop dialog writes through the
// shell, after the OS has confirmed the replace.
if d.path.as_deref().is_some_and(|own| same_file(&path, own)) && p.get("overwrite").and_then(Value::as_bool) != Some(true) {
return Err(EngineError::BadParams {
cmd: "file.saveACopy".into(),
msg: "`path` is the document's own file, so the copy would replace it: choose another path, or pass \"overwrite\": true".into(),
});
}
let mut doc = (*d.doc).clone();
if p.get("layers").and_then(Value::as_bool) == Some(false) {
// "Layers" unchecked in Save a Copy: write the flattened image.
let fmt = doc.pixel_format();
let px = flattened(&doc, fmt);
doc.layers = vec![Layer::new("Background", LayerContent::Raster(px))];
}
let warnings = save_doc(&doc, &path, SaveOpts::from_params(s, p, "file.saveACopy")?)?;
Ok(json!({"path": path, "warnings": warnings}))
}
/// Open a file's bytes, decoding them as the format `as_ext` (Open As) when given.
/// EXR/HDR rarely states its colour space, so io tags it with the linear-sRGB default.
/// With File Handling's ACEScg option on, such an EXR opens as ACEScg instead (AP1,
/// linear) — the working space ACES render pipelines write. Only the io default is
/// replaced; an embedded or EXIF-declared profile is kept untouched.
pub(crate) fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) {
if !name.to_ascii_lowercase().ends_with(".exr") || !s.prefs.get().file_handling.exr_aces_cg {
return;
}
let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes();
if doc.icc_profile.as_ref().map(|v| v.as_slice()) == Some(linear.as_slice()) {
doc.icc_profile = Some(photocraft_cms::Builtin::AcesCg.profile().to_bytes());
}
}
pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&str>, path: Option<String>) -> Result<Value> {
crate::allocation::checkpoint("importing document")?;
let decode_name = match as_ext {
Some(ext) => format!("{}.{}", stem(name), ext.trim_start_matches('.')),
None => name.to_string(),
};
let r = photocraft_io::import(&decode_name, bytes).map_err(|e| EngineError::Other(format!("{decode_name}: {e}")))?;
let mut doc = r.document;
tag_exr_colorspace(s, &decode_name, &mut doc);
let path = match template_name(s, name) {
Some(untitled) => {
doc.name = untitled;
None
}
None => {
doc.name = file_name(name);
path
}
};
// Color Settings policies (preserve / convert / discard the embedded profile).
let (i, color) = s.open_document(doc, path.filter(|_| !r.source_read_only));
if let Some(st) = s.active_mut() {
st.source_read_only = r.source_read_only;
}
// Import notes (e.g. how a camera raw was developed, or that only its preview opened).
Ok(json!({"document": i, "color": color, "warnings": r.warnings}))
}
fn open_as(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path", "file.openAs")?.to_string();
let bytes = read_file(&path)?;
let as_ext = p.get("as").or_else(|| p.get("format")).and_then(Value::as_str);
open_bytes_as(s, &path, &bytes, as_ext, Some(path.clone()))
}
// ---------- place ----------
/// History label of an embedded place.
pub const PLACE_EMBEDDED: &str = "Place Embedded";
/// Place a file's bytes as a smart object layer, centred, and (with Preferences ▸ General ▸
/// Resize Image During Place, on by default) scaled down to fit when larger than the canvas.
/// `linked` makes it a linked smart object that refers to that path instead of embedding the bytes.
/// With Preferences ▸ General ▸ Always Create Smart Objects When Placing off, an embedded place
/// lands as a plain pixel layer instead (an explicit `"smartObject": false` asks for one).
pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec<u8>, linked: Option<String>, p: &Value) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let (cw, ch) = (d.doc.size.width as f64, d.doc.size.height as f64);
let fmt = d.doc.pixel_format();
let vector = photocraft_io::svg::is_svg(&bytes);
let max_svg_group_depth = s.prefs().file_handling.rasterize_svg_groups_deeper_than as usize;
let imported = photocraft_io::import_with_svg_group_depth(name, &bytes, max_svg_group_depth).map_err(|e| EngineError::Other(format!("{name}: {e}")))?;
if imported.preview_only {
return Err(EngineError::Other(format!(
"{name}: only this Affinity file's embedded preview could be read; open it with File › Open to see the warning, or export PSD or PNG from Affinity before using it here"
)));
}
let src = imported.document;
let (w, h) = (src.size.width as f64, src.size.height as f64);
// The implicit `fit` is Preferences ▸ General ▸ Resize Image During Place (on by default).
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(s.prefs().general.resize_image_during_place);
let scale = match f64_param(p, "scale") {
Some(k) => (k / 100.0).max(1e-4),
None if fit && (w > cw || h > ch) => (cw / w).min(ch / h),
None => 1.0,
};
let center = match p.get("center").and_then(Value::as_array) {
Some(c) if c.len() == 2 => (c[0].as_f64().unwrap_or(cw / 2.0), c[1].as_f64().unwrap_or(ch / 2.0)),
_ => (cw / 2.0, ch / 2.0),
};
let (dx, dy) = ((center.0 - w * scale / 2.0).round(), (center.1 - h * scale / 2.0).round());
let mut px = flattened(&src, fmt);
if (scale - 1.0).abs() > 1e-9 {
px = resize_surface(&px, scale, scale, Resample::Bicubic);
}
let px = translate_surface(&px, dx as i32, dy as i32);
// Preferences ▸ General ▸ Always Create Smart Objects When Placing (on by default): off, an
// embedded place lands as a plain pixel layer. A linked place is always a smart object.
let smart = linked.is_some() || p.get("smartObject").and_then(Value::as_bool).unwrap_or(s.prefs().general.always_create_smart_objects_when_placing);
let source = match linked {
Some(path) => SmartSource::Linked { path },
None => SmartSource::Embedded { file_name: file_name(name), bytes: Arc::new(bytes) },
};
let layer_name = stem(name);
let label = if matches!(&source, SmartSource::Linked { .. }) { "Place Linked" } else { PLACE_EMBEDDED };
let id = s.edit(label, |doc, active| {
let layer = if smart {
let mut so = SmartObject::new(source, Affine { m: [scale, 0.0, 0.0, scale, dx, dy] }, Some(px));
// A vector source (SVG) renders at its placement scale instead of as a resampled raster.
if vector && let Some(sharp) = crate::smart_cmds::render(doc, &so)? {
so.cache = Some(sharp);
}
Layer::new(layer_name, LayerContent::Smart(so))
} else {
Layer::new(layer_name, LayerContent::Raster(px))
};
let id = doc.insert_above(*active, layer);
*active = Some(id);
Ok(id)
})?;
Ok(json!({"layer": id.0, "scale": scale * 100.0, "bounds": [dx, dy, dx + w * scale, dy + h * scale], "smartObject": smart}))
}
fn place(s: &mut Session, p: &Value, linked: bool) -> Result<Value> {
let cmd = if linked { "file.placeLinked" } else { "file.placeEmbedded" };
let path = str_param(p, "path", cmd)?.to_string();
let bytes = read_file(&path)?;
// The link is stored absolute: a relative one would later be taken from the document's
// folder (`doc_path`), not the working directory the path was given in.
let link = linked.then(|| {
if std::path::Path::new(&path).is_relative() {
std::path::absolute(&path).map_or_else(|_| path.clone(), |a| a.to_string_lossy().into_owned())
} else {
path.clone()
}
});
place_bytes(s, &path, bytes, link, p)
}
// ---------- File Info (XMP) ----------
/// The File Info fields we edit, as (JSON key, XMP property, container kind).
const INFO_FIELDS: [(&str, &str, &str); 6] = [
("title", "dc:title", "Alt"),
("author", "dc:creator", "Seq"),
("description", "dc:description", "Alt"),
("keywords", "dc:subject", "Bag"),
("copyright", "dc:rights", "Alt"),
("authorTitle", "photoshop:AuthorsPosition", ""),
];
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
fn xml_unescape(s: &str) -> String {
s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
}
/// The element `<tag …>…</tag>` (first occurrence) as (start, end, inner).
fn find_element<'a>(xmp: &'a str, tag: &str) -> Option<(usize, usize, &'a str)> {
let open = format!("<{tag}");
let mut from = 0;
while let Some(i) = xmp[from..].find(&open).map(|i| i + from) {
let after = xmp[i + open.len()..].chars().next();
if matches!(after, Some('>' | ' ' | '/' | '\n' | '\r' | '\t')) {
let gt = xmp[i..].find('>')? + i;
if xmp[..gt].ends_with('/') {
return Some((i, gt + 1, ""));
}
let close = format!("</{tag}>");
let c = xmp[gt..].find(&close)? + gt;
return Some((i, c + close.len(), &xmp[gt + 1..c]));
}
from = i + open.len();
}
None
}
/// `rdf:li` values of a container (or the element text for simple properties).
fn li_values(inner: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = inner;
while let Some((a, b, v)) = find_element(rest, "rdf:li") {
out.push(xml_unescape(v.trim()));
rest = &rest[b..];
let _ = a;
}
if out.is_empty() && !inner.contains('<') && !inner.trim().is_empty() {
out.push(xml_unescape(inner.trim()));
}
out
}
/// Attribute form `prop="value"` on an `rdf:Description`.
fn find_attr<'a>(xmp: &'a str, prop: &str) -> Option<(usize, usize, &'a str)> {
let key = format!(" {prop}=\"");
let i = xmp.find(&key)?;
let v0 = i + key.len();
let v1 = xmp[v0..].find('"')? + v0;
Some((i, v1 + 1, &xmp[v0..v1]))
}
/// File Info fields read from an XMP packet.
pub fn read_file_info(xmp: Option<&str>) -> Value {
let mut m = serde_json::Map::new();
let x = xmp.unwrap_or("");
for (key, prop, kind) in INFO_FIELDS {
let vals = match find_element(x, prop) {
Some((_, _, inner)) => li_values(inner),
None => find_attr(x, prop).map(|(_, _, v)| vec![xml_unescape(v)]).unwrap_or_default(),
};
let v = if kind == "Bag" {
json!(vals)
} else if kind == "Seq" {
json!(vals.join("; "))
} else {
json!(vals.into_iter().next().unwrap_or_default())
};
m.insert(key.into(), v);
}
let marked =
find_element(x, "xmpRights:Marked").map(|(_, _, v)| v.trim().to_string()).or_else(|| find_attr(x, "xmpRights:Marked").map(|(_, _, v)| v.to_string()));
m.insert(
"copyrightStatus".into(),
json!(match marked.as_deref() {
Some("True") => "copyrighted",
Some("False") => "publicDomain",
_ => "unknown",
}),
);
let url = find_element(x, "xmpRights:WebStatement")
.map(|(_, _, v)| xml_unescape(v.trim()))
.or_else(|| find_attr(x, "xmpRights:WebStatement").map(|(_, _, v)| xml_unescape(v)));
m.insert("copyrightUrl".into(), json!(url.unwrap_or_default()));
Value::Object(m)
}
/// File Info's read-only Camera Data, as (result key, English label) in display order.
pub const CAMERA_FIELDS: [(&str, &str); 9] = [
("make", "Make"),
("model", "Model"),
("dateTaken", "Date Taken"),
("exposure", "Exposure"),
("fNumber", "F-Stop"),
("iso", "ISO Speed"),
("focalLength", "Focal Length"),
("focalLength35mm", "Focal Length (35 mm)"),
("lens", "Lens"),
];
/// `v` with at most `decimals` decimals and no trailing zeros (33.10 → "33.1", 8.0 → "8").
fn short_num(v: f64, decimals: usize) -> String {
let s = format!("{v:.decimals$}");
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }
}
/// Exposure time as cameras print it: "1/250 s" for fractions of a second, "0.4 s" or "2 s"
/// when the reciprocal isn't a whole number or the exposure is a second or longer.
fn exposure_text(t: f64) -> Option<String> {
if !(t.is_finite() && t > 0.0) {
return None;
}
let r = 1.0 / t;
if t < 1.0 && r.round() > 1.0 && (r - r.round()).abs() <= r * 0.01 {
return Some(format!("1/{} s", r.round()));
}
Some(format!("{} s", short_num(t, if t < 1.0 { 2 } else { 1 })))
}
/// EXIF (`2024:02:17 12:40:19`) or XMP (`2024-02-17T12:40:19.00+01:00`) dates as
/// `2024-02-17 12:40:19`; anything else is shown as stored.
fn date_text(s: &str) -> String {
let b = s.trim().as_bytes();
let digits = |r: &[usize]| r.iter().all(|&i| b.get(i).is_some_and(u8::is_ascii_digit));
let sep = |i: usize, set: &[u8]| b.get(i).is_some_and(|c| set.contains(c));
let ch = |i: usize| b.get(i).map_or('0', |&c| c as char);
let part = |r: std::ops::Range<usize>| r.map(ch).collect::<String>();
if !(digits(&[0, 1, 2, 3, 5, 6, 8, 9]) && sep(4, b":-") && sep(7, b":-")) {
return s.trim().to_string();
}
let date = format!("{}-{}-{}", part(0..4), part(5..7), part(8..10));
if digits(&[11, 12, 14, 15, 17, 18]) && sep(10, b" T") && sep(13, b":") && sep(16, b":") { format!("{date} {}", part(11..19)) } else { date }
}
/// A single XMP value: the first `rdf:li` of a container, element text or attribute.
fn xmp_value(x: &str, prop: &str) -> Option<String> {
let v = match find_element(x, prop) {
Some((_, _, inner)) => li_values(inner).into_iter().next(),
None => find_attr(x, prop).map(|(_, _, v)| xml_unescape(v)),
}?;
let v = v.trim().to_string();
(!v.is_empty()).then_some(v)
}
/// An XMP number: a rational "331/10" or a decimal.
fn xmp_number(x: &str, prop: &str) -> Option<f64> {
let v = xmp_value(x, prop)?;
let n = match v.split_once('/') {
Some((a, b)) => {
let (a, b) = (a.trim().parse::<f64>().ok()?, b.trim().parse::<f64>().ok()?);
(b != 0.0).then(|| a / b)?
}
None => v.parse::<f64>().ok()?,
};
n.is_finite().then_some(n)
}
/// File Info's read-only Camera Data (keys of [`CAMERA_FIELDS`], present ones only), formatted
/// for display. Read from the EXIF block (JPEG APP1, PSD resource 1058, …); properties it lacks
/// fall back to the XMP `tiff:`/`exif:`/`aux:` copies (files without an EXIF block).
pub fn camera_data(exif: Option<&[u8]>, xmp: Option<&str>) -> Value {
let e = exif.map(photocraft_algo::exif::read).unwrap_or_default();
let x = xmp.unwrap_or("");
let text = |v: Option<String>, props: &[&str]| v.or_else(|| props.iter().find_map(|p| xmp_value(x, p)));
let num =
|v: Option<f64>, props: &[&str]| v.filter(|v| v.is_finite() && *v > 0.0).or_else(|| props.iter().find_map(|p| xmp_number(x, p))).filter(|v| *v > 0.0);
let values = [
text(e.make, &["tiff:Make"]),
text(e.model, &["tiff:Model"]),
text(e.date_taken, &["exif:DateTimeOriginal"]).map(|d| date_text(&d)),
num(e.exposure_time, &["exif:ExposureTime"]).and_then(exposure_text),
num(e.f_number, &["exif:FNumber"]).map(|n| format!("f/{}", short_num(n, 1))),
num(e.iso, &["exif:ISOSpeedRatings", "exifEX:PhotographicSensitivity"]).map(|n| format!("ISO {}", n.round())),
num(e.focal_length, &["exif:FocalLength"]).map(|f| format!("{} mm", short_num(f, 1))),
num(e.focal_length_35mm, &["exif:FocalLengthIn35mmFilm"]).map(|f| format!("{} mm", short_num(f, 1))),
text(e.lens, &["exifEX:LensModel", "aux:Lens"]),
];
let mut m = serde_json::Map::new();
for ((key, _), v) in CAMERA_FIELDS.into_iter().zip(values) {
// Hostile files can carry megabytes of "lens name"; the dialog shows a line.
if let Some(v) = v.map(|v| v.chars().take(200).collect::<String>()).filter(|v| !v.is_empty()) {
m.insert(key.into(), json!(v));
}
}
Value::Object(m)
}
/// Rewrites only changed File Info fields of an XMP packet (creating one when there is none),
/// keeping the other properties verbatim. Edited properties move into their own
/// `rdf:Description` (several descriptions per packet are valid XMP).
pub fn write_file_info(xmp: Option<&str>, info: &Value) -> String {
let cur = read_file_info(xmp);
// The dialog submits all fields, including unchanged language alternatives.
let changed = |key: &str| info.get(key).is_some_and(|v| cur.get(key) != Some(v));
let mut x = xmp.filter(|s| s.contains("</rdf:RDF>")).map(str::to_string).unwrap_or_else(|| {
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>".to_string()
});
for (key, prop, _) in INFO_FIELDS.into_iter().chain([("copyrightStatus", "xmpRights:Marked", ""), ("copyrightUrl", "xmpRights:WebStatement", "")]) {
if !changed(key) {
continue;
}
while let Some((a, b, _)) = find_element(&x, prop) {
x.replace_range(a..b, "");
}
while let Some((a, b, _)) = find_attr(&x, prop) {
x.replace_range(a..b, "");
}
}
let get = |k: &str| info.get(k).filter(|_| changed(k)).cloned().unwrap_or(Value::Null);
let mut body = String::new();
for (key, prop, kind) in INFO_FIELDS {
let vals: Vec<String> = match get(key) {
Value::Array(a) => a.iter().filter_map(|v| v.as_str()).map(str::to_string).filter(|v| !v.is_empty()).collect(),
Value::String(s) if kind == "Bag" || kind == "Seq" => s.split([';', ',']).map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect(),
Value::String(s) if !s.is_empty() => vec![s],
_ => Vec::new(),
};
if vals.is_empty() {
continue;
}
let items: String = vals
.iter()
.map(|v| {
if kind == "Alt" { format!("<rdf:li xml:lang=\"x-default\">{}</rdf:li>", xml_escape(v)) } else { format!("<rdf:li>{}</rdf:li>", xml_escape(v)) }
})
.collect();
if kind.is_empty() {
body.push_str(&format!(" <{prop}>{}</{prop}>\n", xml_escape(&vals[0])));
} else {
body.push_str(&format!(" <{prop}><rdf:{kind}>{items}</rdf:{kind}></{prop}>\n"));
}
}
match get("copyrightStatus").as_str() {
Some("copyrighted") => body.push_str(" <xmpRights:Marked>True</xmpRights:Marked>\n"),
Some("publicDomain") => body.push_str(" <xmpRights:Marked>False</xmpRights:Marked>\n"),
_ => {}
}
if let Some(u) = get("copyrightUrl").as_str().filter(|u| !u.is_empty()) {
body.push_str(&format!(" <xmpRights:WebStatement>{}</xmpRights:WebStatement>\n", xml_escape(u)));
}
if !body.is_empty() {
let desc = format!(
" <rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:photoshop=\"http://ns.adobe.com/photoshop/1.0/\" xmlns:xmpRights=\"http://ns.adobe.com/xap/1.0/rights/\">\n{body} </rdf:Description>\n "
);
let at = x.rfind("</rdf:RDF>").unwrap_or(x.len());
x.insert_str(at, &desc);
}
x
}
fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
let keys = ["title", "author", "authorTitle", "description", "keywords", "copyright", "copyrightStatus", "copyrightUrl"];
let edits = p.as_object().is_some_and(|m| m.keys().any(|k| keys.contains(&k.as_str())));
if edits {
let xmp = s.active().ok_or(EngineError::NoDocument)?.doc.metadata.xmp.clone();
let new = write_file_info(xmp.as_deref(), p);
s.edit("File Info", |doc, _| {
doc.metadata.xmp = Some(new);
Ok(())
})?;
}
let d = s.active().ok_or(EngineError::NoDocument)?;
let mut info = read_file_info(d.doc.metadata.xmp.as_deref());
if let Value::Object(m) = &mut info {
m.insert("camera".into(), camera_data(d.doc.metadata.exif.as_deref().map(Vec::as_slice), d.doc.metadata.xmp.as_deref()));
}
Ok(info)
}
// ---------- Automate ----------
fn fit_image(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let (w, h) = (d.doc.size.width as f64, d.doc.size.height as f64);
let bw = size_param(p, "width").unwrap_or(w);
let bh = size_param(p, "height").unwrap_or(h);
let k = (bw / w).min(bh / h);
if (k > 1.0 && p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(false)) || ((w * k).round() == w && (h * k).round() == h) {
return Ok(json!({"width": w, "height": h, "changed": false}));
}
let (nw, nh) = ((w * k).round().max(1.0), (h * k).round().max(1.0));
let resample = p.get("resample").and_then(Value::as_str).unwrap_or("bicubic");
s.execute("image.imageSize", json!({"width": nw, "height": nh, "resample": resample}))?;
Ok(json!({"width": nw, "height": nh, "changed": true}))
}
fn mode_name(m: ColorMode) -> &'static str {
match m {
ColorMode::Bitmap => "bitmap",
ColorMode::Grayscale => "grayscale",
ColorMode::Duotone => "duotone",
ColorMode::Indexed => "indexed",
ColorMode::Rgb => "rgb",
ColorMode::Cmyk => "cmyk",
ColorMode::Lab => "lab",
ColorMode::Multichannel => "multichannel",
}
}
fn conditional_mode_change(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.automate.conditionalModeChange";
let to = str_param(p, "to", cmd)?.to_ascii_lowercase();
let to = match to.as_str() {
"gray" | "grayscale" => "grayscale",
"rgb" => "rgb",
"cmyk" => "cmyk",
"lab" => "lab",
other => return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unsupported target mode \"{other}\" (rgb, grayscale, cmyk, lab)") }),
};
let cur = mode_name(s.active().ok_or(EngineError::NoDocument)?.doc.mode);
// One spelling per source mode, for the array and the single-string forms alike (#7042).
let norm = |v: &str| {
let v = v.to_ascii_lowercase();
if v == "gray" { "grayscale".to_string() } else { v }
};
let from: Vec<String> = match p.get("from") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(norm).collect(),
Some(Value::String(v)) => vec![norm(v)],
_ => vec!["any".into()],
};
if cur == to || !(from.iter().any(|f| f == "any" || f == cur)) {
return Ok(json!({"changed": false, "mode": cur}));
}
s.execute(&format!("image.mode.{to}"), json!({}))?;
Ok(json!({"changed": true, "mode": to}))
}
/// One `[id, params]` / `{command, params}` / `{id, params}` step of an action.
fn parse_steps(v: &Value) -> Result<Vec<(String, Value)>> {
let bad = |m: &str| EngineError::BadParams { cmd: "file.automate.batch".into(), msg: m.into() };
let arr = v.as_array().ok_or_else(|| bad("\"steps\" must be an array"))?;
arr.iter()
.map(|st| match st {
Value::Array(a) if !a.is_empty() => {
Ok((a[0].as_str().ok_or_else(|| bad("step id must be a string"))?.to_string(), a.get(1).cloned().unwrap_or(json!({}))))
}
Value::Object(o) => {
let id = o.get("command").or_else(|| o.get("id")).and_then(Value::as_str).ok_or_else(|| bad("step needs \"command\""))?;
Ok((id.to_string(), o.get("params").cloned().unwrap_or(json!({}))))
}
Value::String(id) => Ok((id.clone(), json!({}))),
_ => Err(bad("each step is [id, params] or {\"command\", \"params\"}")),
})
.collect()
}
/// Inputs of a batch command: `"input"` is a folder or an array of files.
pub(crate) fn batch_inputs(p: &Value, cmd: &str) -> Result<Vec<String>> {
match p.get("input").or_else(|| p.get("files")) {
Some(Value::Array(a)) => Ok(a.iter().filter_map(Value::as_str).map(str::to_string).collect()),
Some(Value::String(dir)) => list_images(dir),
_ => Err(EngineError::BadParams { cmd: cmd.into(), msg: "missing \"input\" (folder or array of files)".into() }),
}
}
/// One input of a batch run: the file, and the folder it sits in relative to the input folder
/// (`""` at the top level, `"a/b"` two levels down), which its result keeps under `output`.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct BatchInput {
pub path: String,
pub rel_dir: String,
}
/// Subfolders below a batch input folder are walked at most this deep ("Include All Subfolders").
pub(crate) const MAX_SUBFOLDER_DEPTH: usize = 32;
/// A batch over subfolders lists at most this many files; a larger tree is an error, not a run
/// that silently stops partway.
pub(crate) const MAX_SUBFOLDER_FILES: usize = 100_000;
/// [`batch_inputs`] for the commands whose Photoshop dialog has "Include All Subfolders" (Batch,
/// Image Processor): with `"includeSubfolders": true` a folder input also yields the images in its
/// subfolders, each tagged with its relative folder. `output` (when inside the input folder, as
/// the dialog's default `<input>/batch` is) is not walked, so a rerun doesn't process its results.
pub(crate) fn batch_inputs_tree(p: &Value, cmd: &str, output: &str) -> Result<Vec<BatchInput>> {
let recurse = match p.get("includeSubfolders") {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"includeSubfolders\" must be true or false".into() }),
};
match p.get("input").or_else(|| p.get("files")) {
Some(Value::String(dir)) if recurse => list_images_tree(dir, output, cmd),
_ => Ok(batch_inputs(p, cmd)?.into_iter().map(|path| BatchInput { path, rel_dir: String::new() }).collect()),
}
}
/// The images under `dir` and its subfolders: each folder's files sorted by name, then its
/// subfolders in name order (depth first). Bounded by [`MAX_SUBFOLDER_DEPTH`] and
/// [`MAX_SUBFOLDER_FILES`]; a folder reached twice (a symlink loop) is walked once, and `skip`
/// (the output folder) is left out.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn list_images_tree(dir: &str, skip: &str, cmd: &str) -> Result<Vec<BatchInput>> {
use std::path::{Path, PathBuf};
let canon = |p: &Path| std::fs::canonicalize(p).ok();
let root = Path::new(dir);
let root_canon = canon(root).ok_or_else(|| EngineError::Other(format!("{dir}: cannot open the folder")))?;
let skip = if skip.is_empty() { None } else { canon(Path::new(skip)) };
let mut seen = std::collections::HashSet::new();
seen.insert(root_canon);
let mut out = Vec::new();
// Depth first with an explicit stack (no recursion): (folder, relative folder, depth).
let mut stack: Vec<(PathBuf, String, usize)> = vec![(root.to_path_buf(), String::new(), 0)];
while let Some((folder, rel, depth)) = stack.pop() {
let shown = folder.to_string_lossy().into_owned();
// The top folder must be readable; a subfolder that isn't is skipped.
let files = match list_images(&shown) {
Ok(f) => f,
Err(e) if depth == 0 => return Err(e),
Err(_) => continue,
};
if out.len().saturating_add(files.len()) > MAX_SUBFOLDER_FILES {
return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("{dir} holds more than {MAX_SUBFOLDER_FILES} images with its subfolders") });
}
out.extend(files.into_iter().map(|path| BatchInput { path, rel_dir: rel.clone() }));
if depth >= MAX_SUBFOLDER_DEPTH {
continue;
}
let Ok(rd) = std::fs::read_dir(&folder) else { continue };
let mut subs: Vec<(String, PathBuf)> = rd
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_dir())
.filter_map(|p| Some((p.file_name()?.to_string_lossy().into_owned(), p)))
.collect();
subs.sort();
// Pushed in reverse so the stack pops them in name order.
for (name, sub) in subs.into_iter().rev() {
let Some(c) = canon(&sub) else { continue };
if skip.as_ref() == Some(&c) || !seen.insert(c) {
continue;
}
let rel = if rel.is_empty() { name } else { format!("{rel}/{name}") };
stack.push((sub, rel, depth + 1));
}
}
Ok(out)
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn list_images_tree(dir: &str, _skip: &str, _cmd: &str) -> Result<Vec<BatchInput>> {
Err(EngineError::Other(format!("cannot list {dir}: no file system on the web")))
}
/// Opens each input in a scratch session, runs `f` on it and saves it to `output` as `format`
/// (`"same"` keeps the input's extension). Errors per file are collected, not fatal.
pub(crate) fn process_files(inputs: &[String], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value {
let inputs: Vec<BatchInput> = inputs.iter().map(|path| BatchInput { path: path.clone(), rel_dir: String::new() }).collect();
process_tree(&inputs, output, format, save, suffix, f)
}
/// [`process_files`] for inputs from subfolders: each result goes to its input's relative folder
/// under `output` (created as needed), so `a/x.png` and `b/x.png` don't collide.
pub(crate) fn process_tree(inputs: &[BatchInput], output: &str, format: &str, save: SaveOpts, suffix: &str, f: &dyn Fn(&mut Session) -> Result<()>) -> Value {
let outputs = inputs.iter().map(|BatchInput { path, rel_dir }| {
let ext = if format == "same" { same_format(path) } else { format.trim_start_matches('.').to_ascii_lowercase() };
(path.as_str(), join(&join(output, rel_dir), &format!("{}{suffix}.{ext}", stem(path))))
});
process_outputs(outputs, save, f)
}
/// The extension a batch output takes with Format "Same": the input's own, except for formats
/// PhotoCraft opens but can't write, camera raws above all (#5799: a folder of ARWs failed on
/// every file). Those are saved as PSD, the format Photoshop's Save As offers for a raw. An input
/// whose file name has no extension (a dot in a folder name doesn't count) is saved as PNG.
fn same_format(path: &str) -> String {
const IMPORT_ONLY: &[&str] = &["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub", "heic", "heif", "hif", "svg", "svgz", "cin", "pcx", "xpm"];
let ext = extension(path).unwrap_or_else(|| "png".into());
if photocraft_io::raw::EXTENSIONS.contains(&ext.as_str()) || IMPORT_ONLY.contains(&ext.as_str()) { "psd".into() } else { ext }
}
#[cfg(test)]
#[test]
fn same_format_takes_the_file_names_extension() {
assert_eq!(same_format("/in/a.PNG"), "png");
assert_eq!(same_format("/in/IMG_0001.ARW"), "psd");
assert_eq!(same_format("/in/a.kra"), "psd");
assert_eq!(same_format("/in/IMG_0001.HEIC"), "psd");
assert_eq!(same_format("/in/logo.svg"), "psd");
assert_eq!(same_format("/in/scan.cin"), "psd");
// No extension in the file name: PNG, not the path after a dot in a folder name.
assert_eq!(same_format("/in/v1.2/scan"), "png");
assert_eq!(same_format("C:\\in.d\\scan"), "png");
assert_eq!(same_format("/in/.hidden"), "png");
}
fn process_outputs<'a>(outputs: impl IntoIterator<Item = (&'a str, String)>, save: SaveOpts, f: &dyn Fn(&mut Session) -> Result<()>) -> Value {
let mut files = Vec::new();
let mut errors = Vec::new();
let mut written = OutputClaims::default();
for (path, out) in outputs {
let r = (|| -> Result<()> {
written.check(&out).map_err(EngineError::Other)?;
let bytes = read_file(path)?;
let mut scratch = Session::new();
let doc = import(&file_name(path), &bytes)?;
scratch.add_document(doc, Some(path.to_string()));
f(&mut scratch)?;
let d = scratch.active().ok_or(EngineError::NoDocument)?;
save_doc(&d.doc, &out, save)?;
Ok(())
})();
match r {
Ok(()) => {
written.record(&out, path);
files.push(out);
}
Err(e) => errors.push(json!({"file": path, "error": e.to_string()})),
}
}
json!({"files": files, "errors": errors})
}
/// The output paths a batch run has written, so a later input whose output name matches an
/// earlier one's (`a.png` and `a.jpg` saved as JPEG, or the same name in two input folders) is
/// reported instead of silently replacing that result (#420, #422). Names are compared ignoring
/// case, because macOS and Windows file systems do.
#[derive(Default)]
pub struct OutputClaims(std::collections::HashMap<String, String>);
impl OutputClaims {
/// `Err` (naming the earlier input) when `out` was already written in this run.
pub fn check(&self, out: &str) -> std::result::Result<(), String> {
match self.0.get(&out.to_lowercase()) {
Some(first) => Err(format!("not written: {out} already holds the result of {first} from this run (same output name)")),
None => Ok(()),
}
}
/// Remember that `input`'s result was written to `out`.
pub fn record(&mut self, out: &str, input: &str) {
self.0.insert(out.to_lowercase(), input.to_string());
}
}
fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.automate.batch";
let mut steps =
parse_steps(p.get("steps").or_else(|| p.get("action")).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "missing \"steps\"".into() })?)?;
// Each result goes to the destination folder, which overrides the action's recorded Save /
// Save As steps (Photoshop's "Override Action 'Save As' Commands"). Recorded view steps
// (zoom, fit) don't change the output, so they are skipped (#2752).
steps.retain(|(id, _)| !crate::actions_cmds::shell_save_command(id) && !crate::actions_cmds::shell_view_command(id) && id != "file.export.quickExport");
if let Some((id, _)) = steps.iter().find(|(id, _)| crate::commands::find(id).is_none()) {
return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") });
}
let output = str_param(p, "output", cmd)?.to_string();
let inputs = batch_inputs_tree(p, cmd, &output)?;
let format = p.get("format").and_then(Value::as_str).unwrap_or("same").to_string();
// Steps that call another action play it from the caller's actions; the playback stack comes
// along so an action that batches itself is caught as a recursive call.
let list = match p.get("calledActions") {
None | Some(Value::Null) => s.actions.list.clone(),
// A droplet brings the actions it calls (#2794) and runs with those alone.
Some(v) => {
let list: Vec<crate::actions_cmds::Action> =
serde_json::from_value(v.clone()).map_err(|e| EngineError::BadParams { cmd: cmd.into(), msg: format!("\"calledActions\": {e}") })?;
if list.len() > 4096 {
return Err(EngineError::BadParams { cmd: cmd.into(), msg: "\"calledActions\" holds more than 4096 actions".into() });
}
list
}
};
let actions = crate::actions_cmds::ActionState { list, playing: s.actions.playing, playback_stack: s.actions.playback_stack.clone(), ..Default::default() };
let names = batch_naming::plan(&inputs, &output, &format, p)?;
let save = SaveOpts::from_params(s, p, cmd)?;
let authorize = s.authorize;
let run = move |scratch: &mut Session| {
scratch.authorize = authorize;
scratch.actions = actions.clone();
for (id, params) in &steps {
let r = scratch.execute(id, params.clone())?;
// A failed step inside a called action makes this file an error, not a saved result.
if let Some(error) = crate::actions_cmds::nested_failure(id, &r) {
return Err(EngineError::Other(error));
}
}
Ok(())
};
let r = match names {
Some(names) => process_outputs(inputs.iter().map(|i| i.path.as_str()).zip(names), save, &run),
None => process_tree(&inputs, &output, &format, save, "", &run),
};
Ok(r)
}
fn image_processor(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.scripts.imageProcessor";
let output = str_param(p, "output", cmd)?.to_string();
let inputs = batch_inputs_tree(p, cmd, &output)?;
let format = p.get("format").and_then(Value::as_str).unwrap_or("jpg").to_string();
let fit = match (size_param(p, "width"), size_param(p, "height")) {
(None, None) => None,
(w, h) => Some(json!({"width": w.unwrap_or(1e9), "height": h.unwrap_or(1e9), "dontEnlarge": true})),
};
let to_srgb = p.get("convertToSrgb").and_then(Value::as_bool).unwrap_or(false);
let r = process_tree(&inputs, &output, &format, SaveOpts::from_params(s, p, cmd)?.or_quality(8.0), "", &|scratch| {
if to_srgb && scratch.active().is_some_and(|d| d.doc.mode != ColorMode::Rgb) {
scratch.execute("image.mode.rgb", json!({}))?;
}
if let Some(fp) = &fit {
fit_image(scratch, fp)?;
}
Ok(())
});
Ok(r)
}
fn load_files_into_stack(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.scripts.loadFilesIntoStack";
let paths: Vec<String> = match p.get("paths").or_else(|| p.get("input")) {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
Some(Value::String(dir)) => list_images(dir)?,
_ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "missing \"paths\"".into() }),
};
if paths.is_empty() {
return Err(EngineError::BadParams { cmd: cmd.into(), msg: "no files to load".into() });
}
let mut docs = Vec::new();
for path in &paths {
let bytes = read_file(path)?;
docs.push((file_name(path), import(&file_name(path), &bytes)?));
}
let first = &docs[0].1;
let w = docs.iter().map(|(_, d)| d.size.width).max().unwrap_or(1);
let h = docs.iter().map(|(_, d)| d.size.height).max().unwrap_or(1);
let mut stack = Document::new(stem(&paths[0]), photocraft_doc::Size::new(w, h), first.mode, first.depth);
stack.resolution_dpi = first.resolution_dpi;
stack.icc_profile = first.icc_profile.clone();
let fmt = stack.pixel_format();
// First file at the bottom, like Photoshop's script.
for (name, d) in &docs {
stack.layers.push(Layer::new(name.clone(), LayerContent::Raster(flattened(d, fmt))));
}
let n = stack.layers.len();
let smart = p.get("createSmartObject").and_then(Value::as_bool).unwrap_or(false);
// "Attempt to Automatically Align Source Images": Auto-Align Layers on the loaded layers, before
// they go into the smart object.
let align = p.get("align").and_then(Value::as_bool).unwrap_or(false) && n > 1;
if smart && !align {
// "Create Smart Object after Loading Layers": the layers go inside one smart object, ready
// for Layer › Smart Objects › Stack Mode. Like Photoshop, they sit at the top level of the
// contents, with no wrapper group.
let layers = std::mem::take(&mut stack.layers);
let smart = crate::smart_cmds::layers_to_smart(&stack, &stem(&paths[0]), layers)?;
stack.layers = vec![smart];
}
let i = s.add_document(stack, None);
if align {
s.execute("select.allLayers", json!({}))?;
s.execute("edit.autoAlignLayers", json!({"projection": "auto"}))?;
if smart {
let name = s.active().map(|d| d.doc.name.clone()).unwrap_or_default();
s.edit("Convert to Smart Object", |doc, active| {
let layers = std::mem::take(&mut doc.layers);
let smart = crate::smart_cmds::layers_to_smart(doc, &name, layers)?;
*active = Some(smart.id);
doc.layers = vec![smart];
Ok(())
})?;
if let Some(st) = s.active_mut() {
st.selected_layers = st.active_layer.into_iter().collect();
}
}
}
Ok(json!({"document": i, "layers": n}))
}
// ---------- Scripts: flatten effects / masks ----------
fn has_live_effects(l: &Layer) -> bool {
l.effects.enabled && l.effects.items.iter().any(|e| e.enabled())
}
/// The layer of `doc` rendered alone (keeping what `keep` says) with the document's Global Light,
/// patterns and canvas, as a surface in the document's format. With `apply_mask` off the layer's
/// pixel and vector masks are left out of the render, so a caller that keeps them live on the
/// baked raster does not mask the pixels twice.
fn bake(doc: &Document, l: &Layer, keep_effects: bool, apply_mask: bool) -> Surface {
let canvas = doc.bounds();
let mut tmp = l.clone();
tmp.opacity = 1.0;
tmp.blend = photocraft_color::BlendMode::Normal;
tmp.visible = true;
tmp.clipped = false;
if !keep_effects {
tmp.effects.items.clear();
tmp.fill_opacity = 1.0;
}
if !apply_mask {
tmp.mask = None;
tmp.vector_mask = None;
}
// Render over the union of the canvas and the content so off-canvas pixels survive.
let area = l.surface().map_or(canvas, |s| s.content_bounds().union(&canvas));
let area = if keep_effects { area.inflate(256) } else { area };
buffer_surface(&photocraft_compose::render_layer_in(doc, &tmp, area), doc.pixel_format())
}
fn walk_ids(doc: &Document) -> Vec<LayerId> {
doc.walk().into_iter().map(|(_, _, l)| l.id).collect()
}
fn flatten_all_effects(s: &mut Session) -> Result<Value> {
let mut n = 0;
s.edit("Flatten All Layer Effects", |doc, _| {
for id in walk_ids(doc) {
let Some(l) = doc.layer(id) else { continue };
if !has_live_effects(l) || matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_)) {
continue;
}
// The pixel and vector masks stay on the layer (baking must not destroy them), so the
// pixels are baked without them and each mask then applies once to the baked raster.
let px = bake(doc, l, true, false);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.content = LayerContent::Raster(px);
l.effects = Default::default();
l.fill_opacity = 1.0;
l.fill_cache = None;
l.psd_blocks.retain(|(k, _)| !matches!(k, b"TySh" | b"SoLd" | b"PlLd" | b"SoLE" | b"vmsk" | b"vsms" | b"vogk" | b"vscg" | b"vstk"));
n += 1;
}
Ok(())
})?;
Ok(json!({"flattened": n}))
}
fn flatten_all_masks(s: &mut Session) -> Result<Value> {
let (mut n, mut skipped) = (0, 0);
s.edit("Flatten All Masks", |doc, _| {
for id in walk_ids(doc) {
let Some(l) = doc.layer(id) else { continue };
let masked = l.mask.as_ref().is_some_and(|m| m.enabled) || l.vector_mask.is_some();
if !masked {
continue;
}
if !matches!(l.content, LayerContent::Raster(_)) {
// Masks on groups, adjustment, type, shape and smart layers have no pixels to
// fold into; Photoshop's script leaves them too.
skipped += 1;
continue;
}
let px = bake(doc, l, false, true);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.content = LayerContent::Raster(px);
l.mask = None;
l.vector_mask = None;
l.psd_blocks.retain(|(k, _)| !matches!(k, b"vmsk" | b"vsms"));
n += 1;
}
Ok(())
})?;
Ok(json!({"flattened": n, "skipped": skipped}))
}
// ---------- Export ----------
fn layers_to_files(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.export.layersToFiles";
let dir = str_param(p, "dir", cmd).or_else(|_| str_param(p, "output", cmd))?.to_string();
let names = export_layers_with(s, p, |name, bytes| write_file(&join(&dir, name), bytes))?;
let files: Vec<String> = names.iter().map(|name| join(&dir, name)).collect();
Ok(json!({"files": files}))
}
/// The bodies of `file.export.layersToFiles`, streamed to `write` as a single-component filename
/// plus its bytes: one file per exported layer (top to bottom, like Photoshop's numbering),
/// encoded but never touched as a path. Automation writes exactly these bytes beneath its write
/// root (#3216), so the two paths cannot drift. Returns the filenames written, in order.
pub fn export_layers_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Vec<String>> {
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 visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true);
let save = SaveOpts::from_params(s, p, cmd)?;
let d = s.active().ok_or(EngineError::NoDocument)?;
let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name));
let doc = d.doc.clone();
let mut written = Vec::new();
// Top-level layers (groups export merged), top to bottom like Photoshop's numbering.
let layers: Vec<&Layer> = doc.layers.iter().rev().filter(|l| l.visible || !visible_only).collect();
for (i, l) in layers.iter().enumerate() {
if matches!(l.content, LayerContent::Adjustment(_)) {
continue;
}
let mut one = (*doc).clone();
let mut only = (*l).clone();
only.visible = true;
only.clipped = false;
one.layers = vec![only];
let name = format!("{}_{:04}_{}.{format}", sanitize(&prefix), i, sanitize(&l.name));
let (bytes, _warnings) = encode(&one, &name, save)?;
write(&name, &bytes)?;
written.push(name);
}
Ok(written)
}
/// Adjustment layers that can be represented as an RGB-only LUT, in document stack order.
/// A LUT has no spatial coordinates, so masks and clipping geometry are deliberately stripped
/// at bake time. Group adjustments need their enclosing group's compositing semantics and are
/// not silently flattened as independent layers.
fn lut_layers<'a>(doc: &'a Document, ids: Option<&[LayerId]>) -> Result<Vec<&'a Layer>> {
const CMD: &str = "file.export.colorLookupTables";
let mut selected = Vec::new();
if let Some(ids) = ids {
if ids.is_empty() {
return Err(EngineError::BadParams { cmd: CMD.into(), msg: "select at least one adjustment layer".into() });
}
for id in ids {
if selected.contains(id) {
return Err(EngineError::BadParams { cmd: CMD.into(), msg: format!("duplicate layer id {}", id.0) });
}
let Some(layer) = doc.layer(*id) else { return Err(EngineError::NoLayer(*id)) };
if !matches!(layer.content, LayerContent::Adjustment(_)) {
return Err(EngineError::BadParams { cmd: CMD.into(), msg: format!("layer {} is not an adjustment layer", id.0) });
}
if !layer.visible {
return Err(EngineError::BadParams { cmd: CMD.into(), msg: format!("layer {} is hidden", id.0) });
}
if !doc.layers.iter().any(|root| root.id == *id) {
return Err(EngineError::BadParams {
cmd: CMD.into(),
msg: format!("layer {} is nested in a group; export top-level adjustment layers", id.0),
});
}
selected.push(*id);
}
}
let mut layers = Vec::new();
for l in &doc.layers {
if l.visible && matches!(l.content, LayerContent::Adjustment(_)) && (ids.is_none() || selected.contains(&l.id)) {
layers.push(l);
}
}
if layers.is_empty() {
return Err(EngineError::BadParams { cmd: CMD.into(), msg: "no visible adjustment layers to export".into() });
}
Ok(layers)
}
/// Bake the chosen adjustment stack against an identity RGB lattice.
/// Source order always follows the document stack, even if an explicit list is reversed.
fn bake_cube_layers(size: usize, title: &str, layers: &[&Layer]) -> String {
let n = size;
let (w, h) = ((n * n) as u32, n as u32);
let mut lattice = Document::new("lut", photocraft_doc::Size::new(w, h), ColorMode::Rgb, photocraft_color::SampleType::F32);
let fmt = lattice.pixel_format();
let step = 1.0 / (n - 1) as f32;
let mut data = Vec::with_capacity((w * h) as usize * fmt.channels());
for g in 0..n {
for b in 0..n {
for r in 0..n {
let mut px = [0.0f32; 8];
let k = from_rgba_into(&fmt, [r as f32 * step, g as f32 * step, b as f32 * step, 1.0], &mut px);
data.extend_from_slice(&px[..k]);
}
}
}
let mut surf = Surface::new(fmt);
surf.write_region(Rect::new(0, 0, w as i32, h as i32), &data);
lattice.layers.push(Layer::new("Lattice", LayerContent::Raster(surf)));
for l in layers {
let mut a = (*l).clone();
// Masks and clipping are spatial; a LUT is the adjustment stack's colour mapping.
a.mask = None;
a.vector_mask = None;
a.clipped = false;
lattice.layers.push(a);
}
let out = photocraft_compose::flatten(&lattice);
let mut s = format!("TITLE \"{}\"\n# Created by PhotoCraft\nLUT_3D_SIZE {n}\nDOMAIN_MIN 0.0 0.0 0.0\nDOMAIN_MAX 1.0 1.0 1.0\n", title.replace('"', "'"));
// .cube order: red changes fastest, then green, then blue.
for b in 0..n {
for g in 0..n {
for r in 0..n {
let (x, y) = (b * n + r, g);
let px = out.px[y * w as usize + x];
s.push_str(&format!("{:.6} {:.6} {:.6}\n", px[0].clamp(0.0, 1.0), px[1].clamp(0.0, 1.0), px[2].clamp(0.0, 1.0)));
}
}
}
s
}
/// Backwards-compatible helper for exporting all visible top-level adjustments.
pub fn bake_cube(doc: &Document, size: usize, title: &str) -> String {
let layers: Vec<&Layer> = doc.layers.iter().filter(|l| l.visible && matches!(l.content, LayerContent::Adjustment(_))).collect();
bake_cube_layers(size.clamp(2, photocraft_cms::lutfile::MAX_SIZE), title, &layers)
}
/// Export either the whole visible stack (backward-compatible default), the Layers panel's
/// current selection, or an explicit list supplied by scripts/CLI/automation.
fn color_lookup_tables(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "file.export.colorLookupTables";
let d = s.active().ok_or(EngineError::NoDocument)?;
let size = match p.get("size") {
None | Some(Value::Null) => 33,
Some(v) => v.as_u64().ok_or_else(|| EngineError::BadParams { cmd: CMD.into(), msg: "size must be an integer".into() })?,
};
if !(2..=photocraft_cms::lutfile::MAX_SIZE as u64).contains(&size) {
return Err(EngineError::BadParams { cmd: CMD.into(), msg: format!("size must be 2..={}", photocraft_cms::lutfile::MAX_SIZE) });
}
let scope = match p.get("scope") {
None | Some(Value::Null) => "all",
Some(Value::String(s)) => s.as_str(),
Some(_) => return Err(EngineError::BadParams { cmd: CMD.into(), msg: "scope must be all or selected".into() }),
};
if !matches!(scope, "all" | "selected") {
return Err(EngineError::BadParams { cmd: CMD.into(), msg: "scope must be all or selected".into() });
}
if p.get("layers").is_some() && scope == "selected" {
return Err(EngineError::BadParams { cmd: CMD.into(), msg: "use either layers or scope=selected, not both".into() });
}
let explicit: Option<Vec<LayerId>> = if let Some(v) = p.get("layers") {
let Some(a) = v.as_array() else { return Err(EngineError::BadParams { cmd: CMD.into(), msg: "layers must be an array of layer IDs".into() }) };
let mut out = Vec::with_capacity(a.len());
for id in a {
let Some(id) = id.as_u64() else { return Err(EngineError::BadParams { cmd: CMD.into(), msg: "layer IDs must be unsigned integers".into() }) };
out.push(LayerId(id));
}
Some(out)
} else if scope == "selected" {
Some(d.selected_layers())
} else {
None
};
let layers = lut_layers(&d.doc, explicit.as_deref())?;
let count = layers.len();
let title = p.get("title").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name));
let cube = bake_cube_layers(size as usize, &title, &layers);
match p.get("path").and_then(Value::as_str) {
Some(path) => {
write_file(path, cube.as_bytes())?;
Ok(json!({"path": path, "size": size, "layerCount": count}))
}
None => Ok(json!({"cube": cube, "size": size, "layerCount": count})),
}
}
// ---------- guides ----------
fn push_unique(v: &mut Vec<f64>, x: f64) {
if !v.iter().any(|g| (g - x).abs() < 0.01) {
v.push(x);
}
}
fn new_guide_layout(s: &mut Session, p: &Value) -> Result<Value> {
crate::guide_layout::apply(s, p)
}
fn guides_from_shape(s: &mut Session, p: &Value) -> Result<Value> {
let id = layer_param(s, p)?;
let d = s.active().ok_or(EngineError::NoDocument)?;
let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let LayerContent::Shape(sh) = &l.content else { return Err(EngineError::Other("the layer is not a shape layer".into())) };
let (x0, y0, x1, y1) = sh.path.control_bounds().ok_or(EngineError::Other("the shape has no path".into()))?;
s.edit("New Guides From Shape", |doc, _| {
for x in [x0, (x0 + x1) / 2.0, x1] {
push_unique(&mut doc.guides.vertical, x);
}
for y in [y0, (y0 + y1) / 2.0, y1] {
push_unique(&mut doc.guides.horizontal, y);
}
Ok(())
})?;
Ok(json!({"bounds": [x0, y0, x1, y1]}))
}
fn clear_canvas_guides(s: &mut Session) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
// Guides inside an artboard belong to it (View › Clear Selected Artboard Guides); the rest
// are canvas guides. Without artboards every guide is a canvas guide.
let boards: Vec<Rect> = d.doc.artboards().iter().map(|b| b.2.rect).collect();
let in_x = move |x: f64| boards.iter().any(|r| x > f64::from(r.x0) && x < f64::from(r.x1));
let boards_y: Vec<Rect> = d.doc.artboards().iter().map(|b| b.2.rect).collect();
let in_y = move |y: f64| boards_y.iter().any(|r| y > f64::from(r.y0) && y < f64::from(r.y1));
let n = d.doc.guides.vertical.iter().filter(|x| !in_x(**x)).count() + d.doc.guides.horizontal.iter().filter(|y| !in_y(**y)).count();
if n == 0 {
return Ok(json!({"cleared": 0}));
}
s.edit("Clear Canvas Guides", |doc, _| {
doc.guides.vertical.retain(|x| in_x(*x));
doc.guides.horizontal.retain(|y| in_y(*y));
doc.guides.prune_colors();
Ok(())
})?;
Ok(json!({"cleared": n}))
}
// ---------- registry ----------
pub fn specs() -> Vec<CommandSpec> {
macro_rules! spec {
($id:expr, $label:expr, $menu:expr, $sc:expr, $params:expr, $enabled:expr, $run:expr) => {
CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run }
};
}
vec![
spec!("file.closeAll", "Close All", &["File"], Some("Cmd+Alt+W"), "{}", has_doc, |s, _| close_all(s)),
spec!(
"file.closeOthers",
"Close Others",
&["File"],
Some("Cmd+Alt+P"),
r##"{"document":index? (the one to keep; default active)}"##,
has_doc,
close_others
),
// The document tab's menu (UI-217-6): the tab's own file in the platform file manager.
// Not a Photoshop menu-bar item; enabled when the target document has a saved path.
spec!("file.revealInFinder", "Reveal in Finder", &[], None, r##"{"document":index? (default active),"dryRun":bool=false}"##, native_doc, |s, p| {
let i = match p.get("document") {
Some(v) => v
.as_u64()
.and_then(|v| usize::try_from(v).ok())
.ok_or_else(|| EngineError::BadParams { cmd: "file.revealInFinder".into(), msg: "`document` must be an index".into() })?,
None => s.active_index().ok_or(EngineError::NoDocument)?,
};
let path = s
.documents()
.get(i)
.and_then(|d| d.path.clone())
.ok_or_else(|| EngineError::BadParams { cmd: "file.revealInFinder".into(), msg: format!("document {i} has no saved file") })?;
crate::layer_menu_cmds::reveal(&path, p.get("dryRun").and_then(Value::as_bool).unwrap_or(false))
}),
spec!("file.revert", "Revert", &["File"], Some("F12"), "{} (reloads the saved file as one undoable step)", can_revert, |s, _| revert(s)),
spec!(
"file.saveACopy",
"Save a Copy…",
&["File"],
Some("Cmd+Alt+S"),
r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"overwrite":bool=false (replace the document's own file),"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"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)}"##,
native_doc,
save_a_copy
),
spec!(
"file.openAs",
"Open As…",
&["File"],
Some("Cmd+Alt+Shift+O"),
r##"{"path":str,"as":"psd|png|jpg|tiff|…"? (decode as this format)}"##,
native,
open_as
),
spec!(
"file.placeEmbedded",
"Place Embedded…",
&["File"],
None,
r##"{"path":str,"scale":%? (default: fit when larger than the canvas),"fit":bool? (default: Preferences > General > Resize Image During Place),"smartObject":bool? (default: Preferences > General > Always Create Smart Objects When Placing),"center":[x,y]?}"##,
native_doc,
|s, p| place(s, p, false)
),
spec!(
"file.placeLinked",
"Place Linked…",
&["File"],
None,
r##"{"path":str,"scale":%?,"fit":bool? (default: Preferences > General > Resize Image During Place),"center":[x,y]?}"##,
native_doc,
|s, p| place(s, p, true)
),
spec!(
"file.fileInfo",
"File Info…",
&["File"],
Some("Cmd+Alt+Shift+I"),
r##"{"title":str?,"author":str?,"authorTitle":str?,"description":str?,"keywords":[str]|"a; b"?,"copyright":str?,"copyrightStatus":"unknown|copyrighted|publicDomain"?,"copyrightUrl":str?} (no keys: read; the result also has read-only "camera":{"make","model","dateTaken","exposure","fNumber","iso","focalLength","focalLength35mm","lens"}, present keys only)"##,
has_doc,
file_info
),
spec!(
"file.automate.fitImage",
"Fit Image…",
&["File", "Automate"],
None,
r##"{"width":px,"height":px,"dontEnlarge":bool=false,"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos"="bicubic"}"##,
has_doc,
fit_image
),
spec!(
"file.automate.conditionalModeChange",
"Conditional Mode Change…",
&["File", "Automate"],
None,
r##"{"from":["rgb","grayscale","cmyk","lab","indexed","bitmap",…]|"any"="any","to":"rgb|grayscale|cmyk|lab"}"##,
has_doc,
conditional_mode_change
),
spec!(
"file.automate.batch",
"Batch…",
&["File", "Automate"],
None,
r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"naming":string? (blank keeps source names; {name}, {serial} padded to 3 digits, {date} as YYYY-MM-DD UTC; extension added automatically),"startingSerial":1..4294967295=1 (input position, including failed inputs),"tiffLayers":bool=false,"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),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
native,
batch
),
spec!(
"file.scripts.imageProcessor",
"Image Processor…",
&["File", "Scripts"],
None,
r##"{"input":folder|[paths],"includeSubfolders":bool=false (a folder input also takes the images in its subfolders, up to 32 levels and 100000 files; each result keeps its subfolder under output),"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"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),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##,
native,
image_processor
),
spec!(
"file.scripts.loadFilesIntoStack",
"Load Files into Stack…",
&["File", "Scripts"],
None,
r##"{"paths":[str]|folder,"align":bool=false (Auto-Align Layers on them first),"createSmartObject":bool=false} → new document with one layer per file (inside one smart object with createSmartObject)"##,
native,
load_files_into_stack
),
spec!("file.scripts.flattenAllLayerEffects", "Flatten All Layer Effects", &["File", "Scripts"], None, "{}", has_doc, |s, _| flatten_all_effects(s)),
spec!(
"file.scripts.flattenAllMasks",
"Flatten All Masks",
&["File", "Scripts"],
None,
"{} (pixel layers; masks on other layer kinds are left)",
has_doc,
|s, _| flatten_all_masks(s)
),
spec!(
"file.export.layersToFiles",
"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?,"tiffLayers":bool=false,"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
),
spec!(
"file.export.colorLookupTables",
"Color Lookup Tables…",
&["File", "Export"],
None,
r##"{"path":str? (.cube; omit to return the text),"size":2..129=33,"title":str?,"scope":"all|selected"="all","layers":[id,…]?} (selected layer IDs use original document stacking; masks are spatial and omitted)"##,
has_adjustments,
color_lookup_tables
),
spec!(
"view.newGuideLayout",
"New Guide Layout…",
&["View"],
None,
r##"{"columns":n=0,"width":px?,"gutter":px=0,"rows":n=0,"height":px?,"rowGutter":px=gutter,"margin":px|[top,left,bottom,right]=0,"centerColumns":bool=false,"clearExisting":bool=false,"target":"document|selectedArtboards"="document"}"##,
has_doc,
new_guide_layout
),
spec!("view.newGuidesFromShape", "New Guides From Shape", &["View"], None, r##"{"layer":id?}"##, has_shape, guides_from_shape),
spec!("view.clearCanvasGuides", "Clear Canvas Guides", &["View"], None, "{}", has_doc, |s, _| clear_canvas_guides(s)),
]
}
#[cfg(test)]
#[path = "file_cmds/tests.rs"]
mod tests;
#[cfg(test)]
mod aces_exr_tests {
use super::*;
/// File Handling › "Open color-less EXR as ACEScg": off, an EXR opens on io's linear-sRGB
/// default; on, it opens as ACEScg — the working space ACES render pipelines write.
#[test]
fn exr_aces_cg_preference_retags_the_opened_document() {
let image = photocraft_codecs::Image::from_f32(
4,
4,
photocraft_codecs::ChannelLayout::Rgb,
&[
0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5,
0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0, 0.25, 0.5, 0.75, 1.0,
],
)
.unwrap();
let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::OpenExr, &photocraft_codecs::EncodeOptions::default()).unwrap();
let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes();
let aces = photocraft_cms::Builtin::AcesCg.profile().to_bytes();
// Off (the default): io's linear-sRGB tagging stays.
let mut s = Session::new();
open_bytes_as(&mut s, "shot.exr", &bytes, None, None).unwrap();
let d = &s.active().unwrap().doc;
assert_eq!(d.icc_profile.as_deref().map(|v| v.as_slice()), Some(linear.as_slice()));
// On: the same file opens as ACEScg.
let mut s = Session::new();
s.prefs.edit(|p| p.file_handling.exr_aces_cg = true);
open_bytes_as(&mut s, "shot.exr", &bytes, None, None).unwrap();
let d = &s.active().unwrap().doc;
assert_eq!(d.icc_profile.as_deref().map(|v| v.as_slice()), Some(aces.as_slice()));
}
/// The desktop app opens files as background jobs (File > Open, drag-drop, Open Recent):
/// the ACEScg preference applies there too, not only to `file.openAs`.
#[test]
fn exr_aces_cg_preference_applies_to_background_opens() {
use crate::jobs::{OpenSource, Started};
let image = photocraft_codecs::Image::from_f32(4, 4, photocraft_codecs::ChannelLayout::Rgb, &[0.5; 48]).unwrap();
let bytes =
std::sync::Arc::new(photocraft_codecs::encode(&image, photocraft_codecs::Format::OpenExr, &photocraft_codecs::EncodeOptions::default()).unwrap());
let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes();
let aces = photocraft_cms::Builtin::AcesCg.profile().to_bytes();
for (on, want) in [(false, &linear), (true, &aces)] {
let mut s = Session::new();
s.prefs.edit(|p| p.file_handling.exr_aces_cg = on);
let v = match s.start_open("shot.exr", OpenSource::Bytes(std::sync::Arc::clone(&bytes))).unwrap() {
Started::Job(id) => s.wait_job(id).unwrap(),
Started::Done(v) => v,
};
// ACEScg isn't the working space, so Ask When Opening holds it for the prompt, which
// names the ACEScg profile; linear sRGB (io's HDR default) opens without asking.
let profile = match v.get("held").and_then(Value::as_u64) {
Some(held) => {
assert!(on, "{v}");
assert!(v["color"]["embedded"].as_str().unwrap().starts_with("ACEScg"), "{v}");
s.take_held_open(held).unwrap().0.icc_profile
}
None => {
assert!(v.get("document").is_some(), "{v}");
s.active().unwrap().doc.icc_profile.clone()
}
};
assert_eq!(profile.as_deref().map(|v| v.as_slice()), Some(want.as_slice()), "exr_aces_cg = {on}");
}
}
}