mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Merge origin/main into codex/multi-channel-editing
Integrate main dacecbfea, retaining selected-colour and explicit clipboard targets alongside CMYK and spot-channel tone fixes. Cover explicit spot copying while multiple colour rows are selected.
This commit is contained in:
Generated
+167
-7
@@ -1163,6 +1163,15 @@ dependencies = [
|
||||
"x509-cert",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.13.1"
|
||||
@@ -1341,12 +1350,40 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback",
|
||||
"icu_normalizer",
|
||||
"icu_plurals",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"icu_provider_adapters",
|
||||
"icu_provider_blob",
|
||||
"log",
|
||||
"objc2-foundation 0.3.2",
|
||||
"sys-locale",
|
||||
"unicode-bidi",
|
||||
"windows-registry",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-jpeg"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -1354,11 +1391,11 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.8.9",
|
||||
"roxmltree 0.20.0",
|
||||
"miniz_oxide 0.9.1",
|
||||
"roxmltree 0.21.1",
|
||||
"tempfile",
|
||||
"winprint",
|
||||
"winsafe",
|
||||
@@ -1367,20 +1404,28 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
"egui",
|
||||
"epaint",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"serde",
|
||||
"skrifa",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -2175,6 +2220,17 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "fixed_decimal"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"smallvec",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
@@ -2956,6 +3012,23 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_decimal"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_pattern",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
@@ -3001,6 +3074,9 @@ dependencies = [
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -3010,6 +3086,32 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_pattern"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"either",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
@@ -3039,6 +3141,7 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"postcard",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
@@ -3048,6 +3151,30 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_adapters"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
|
||||
dependencies = [
|
||||
"icu_locale_fallback",
|
||||
"icu_provider",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_blob"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
|
||||
dependencies = [
|
||||
"icu_provider",
|
||||
"postcard",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_segmenter"
|
||||
version = "2.3.0"
|
||||
@@ -3720,6 +3847,9 @@ name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
@@ -5513,6 +5643,16 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||
dependencies = [
|
||||
"cobs",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
@@ -7175,7 +7315,10 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7824,6 +7967,12 @@ dependencies = [
|
||||
"xmlwriter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
@@ -8863,11 +9012,20 @@ dependencies = [
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
@@ -9245,6 +9403,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde_core",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
|
||||
+5
-5
@@ -10,7 +10,7 @@ license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["serde"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||
# internal
|
||||
photocraft-geom = { path = "crates/geom" }
|
||||
photocraft-color = { path = "crates/color" }
|
||||
@@ -39,10 +39,10 @@ photocraft-tablet = { path = "crates/tablet" }
|
||||
photocraft-ipp = { path = "crates/ipp" }
|
||||
|
||||
# external
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -543,6 +543,61 @@ fn image_from_offers(offers: &[String], mut fetch: impl FnMut(&str) -> Option<Ve
|
||||
})
|
||||
}
|
||||
|
||||
/// Largest SVG markup a paste reads from clipboard text (128 MiB, the size an `.svgz` may inflate
|
||||
/// to).
|
||||
const MAX_CLIPBOARD_SVG_BYTES: u64 = photocraft_io::svg::MAX_INFLATED_BYTES;
|
||||
|
||||
/// The pixels of a drawing copied as SVG markup (on macOS and Linux, VectorCraft's Copy leaves
|
||||
/// only the markup, as text): one pixel per SVG unit, scaled down to the SVG import's pixel limit,
|
||||
/// as RGBA8. `None` for text that isn't SVG, markup longer than [`MAX_CLIPBOARD_SVG_BYTES`], and
|
||||
/// markup that doesn't parse.
|
||||
fn image_from_svg_text(text: &str) -> Option<(u32, u32, Vec<u8>)> {
|
||||
if u64::try_from(text.len()).ok()? > MAX_CLIPBOARD_SVG_BYTES || !text.trim_start_matches('\u{feff}').trim_start().starts_with('<') {
|
||||
return None;
|
||||
}
|
||||
crate::crash_guard::guard("Paste", || photocraft_io::svg::rasterize_rgba8(text.as_bytes()).map_err(|e| e.to_string())).ok()
|
||||
}
|
||||
|
||||
/// What a paste reads from the OS clipboard, in this order: the first copied file that is an
|
||||
/// image (Finder also puts the file's icon on the clipboard as an image, which would otherwise
|
||||
/// paste instead of the file), the clipboard's image, then a drawing copied as SVG markup. Each
|
||||
/// read runs only when the ones before it found nothing. A read that fails, such as one of a busy
|
||||
/// clipboard, returns its error, and the reads after it don't run.
|
||||
fn clipboard_image<C>(
|
||||
clipboard: &mut C,
|
||||
files: impl FnOnce(&mut C) -> Result<Option<Vec<PathBuf>>, String>,
|
||||
image: impl FnOnce(&mut C) -> Result<Option<(u32, u32, Vec<u8>)>, String>,
|
||||
text: impl FnOnce(&mut C) -> Result<Option<String>, String>,
|
||||
) -> Result<Option<(u32, u32, Vec<u8>)>, String> {
|
||||
if let Some(img) = files(clipboard)?.and_then(|paths| image_from_files(&paths)) {
|
||||
return Ok(Some(img));
|
||||
}
|
||||
if let Some(img) = image(clipboard)? {
|
||||
return Ok(Some(img));
|
||||
}
|
||||
Ok(text(clipboard)?.and_then(|text| image_from_svg_text(&text)))
|
||||
}
|
||||
|
||||
/// One OS clipboard read as [`clipboard_image`] takes it: no content in the format asked for is
|
||||
/// `None`, and any other failure, such as a busy clipboard or a broken pipe on Wayland, is the
|
||||
/// read's error.
|
||||
fn clipboard_read<T>(read: Result<T, arboard::Error>) -> Result<Option<T>, String> {
|
||||
match read {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(arboard::Error::ContentNotAvailable) => Ok(None),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clipboard text as [`clipboard_image`] takes it: like [`clipboard_read`], except that text that
|
||||
/// isn't UTF-8 is `None`, like other text that isn't SVG markup.
|
||||
fn clipboard_text(read: Result<String, arboard::Error>) -> Result<Option<String>, String> {
|
||||
match read {
|
||||
Err(arboard::Error::ConversionFailure) => Ok(None),
|
||||
read => clipboard_read(read),
|
||||
}
|
||||
}
|
||||
|
||||
/// The shell command that starts this install under XWayland, where winit delivers file drops
|
||||
/// (winit 0.30 has none on Wayland, #386). A Flatpak gets the X11 socket only without the Wayland
|
||||
/// one, an AppImage relaunches by its own path, and anything else is `photocraft` on the PATH.
|
||||
@@ -660,30 +715,24 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
slot.insert(opened.map_err(|e| e.to_string())?)
|
||||
}
|
||||
};
|
||||
// Copied files first: Finder also puts the file's icon on the clipboard as an
|
||||
// image, which would otherwise paste instead of the file.
|
||||
match cb.get().file_list() {
|
||||
Ok(paths) => {
|
||||
if let Some(img) = image_from_files(&paths) {
|
||||
return Ok(Some(img));
|
||||
clipboard_image(
|
||||
cb,
|
||||
|cb| clipboard_read(cb.get().file_list()),
|
||||
|cb| match cb.get_image() {
|
||||
Ok(img) => Ok(Some((img.width as u32, img.height as u32, img.bytes.into_owned()))),
|
||||
Err(error) => {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(image) = wayland_clipboard_image() {
|
||||
return Ok(Some(image));
|
||||
}
|
||||
match error {
|
||||
arboard::Error::ContentNotAvailable => Ok(None),
|
||||
_ => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error @ arboard::Error::ClipboardOccupied) => return Err(error.to_string()),
|
||||
Err(_) => {}
|
||||
}
|
||||
match cb.get_image() {
|
||||
Ok(img) => Ok(Some((img.width as u32, img.height as u32, img.bytes.into_owned()))),
|
||||
Err(error) => {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(image) = wayland_clipboard_image() {
|
||||
return Ok(Some(image));
|
||||
}
|
||||
match error {
|
||||
arboard::Error::ContentNotAvailable => Ok(None),
|
||||
_ => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|cb| clipboard_text(cb.get_text()),
|
||||
)
|
||||
})
|
||||
}),
|
||||
clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())),
|
||||
|
||||
@@ -660,6 +660,175 @@ fn tga_is_trusted_only_with_its_extension() {
|
||||
assert!(image_from_files(&[bin]).is_none());
|
||||
}
|
||||
|
||||
/// A 121 × 81 white rectangle with a 1-unit black stroke, written as markup the way a vector
|
||||
/// app's Copy puts it on the clipboard as text.
|
||||
const COPIED_RECTANGLE: &str = r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="121" height="81" viewBox="0 0 121 81">
|
||||
<g><path d="M0.5 0.5 L120.5 0.5 L120.5 80.5 L0.5 80.5 Z" fill="#ffffff" stroke="#000000"/></g>
|
||||
</svg>
|
||||
"##;
|
||||
|
||||
/// A 40 × 20 drawing whose left half is a red square; the right half has no art.
|
||||
const HALF_RED: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="20" height="20" fill="#ff0000"/></svg>"##;
|
||||
|
||||
/// The clipboard as `clipboard_image` reads it when it holds only `text`.
|
||||
fn from_text(text: &str) -> Option<(u32, u32, Vec<u8>)> {
|
||||
clipboard_image(&mut (), |_| Ok(None), |_| Ok(None), |_| Ok(Some(text.to_string()))).expect("no read fails")
|
||||
}
|
||||
|
||||
fn pixel(img: &(u32, u32, Vec<u8>), x: u32, y: u32) -> [u8; 4] {
|
||||
let at = ((y * img.0 + x) * 4) as usize;
|
||||
img.2[at..at + 4].try_into().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_markup_copied_as_text_pastes_its_pixels() {
|
||||
let img = from_text(COPIED_RECTANGLE).expect("the drawing's pixels");
|
||||
assert_eq!((img.0, img.1, img.2.len()), (121, 81, 121 * 81 * 4), "one pixel per SVG unit");
|
||||
assert_eq!(pixel(&img, 60, 40), [255, 255, 255, 255], "the white fill");
|
||||
for (x, y) in [(0, 40), (120, 40), (60, 0), (60, 80)] {
|
||||
let [r, g, b, a] = pixel(&img, x, y);
|
||||
assert!(a == 255 && r < 8 && g < 8 && b < 8, "the black stroke at {x},{y}: {:?}", [r, g, b, a]);
|
||||
}
|
||||
let img = from_text(HALF_RED).expect("the drawing's pixels");
|
||||
assert_eq!((img.0, img.1), (40, 20));
|
||||
assert_eq!(pixel(&img, 5, 10), [255, 0, 0, 255]);
|
||||
assert_eq!(pixel(&img, 30, 10), [0, 0, 0, 0], "transparent where there is no art");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_is_not_a_drawing_pastes_nothing() {
|
||||
for text in ["", "hello", "<p>not a drawing</p>", "<?xml version=\"1.0\"?><x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>"] {
|
||||
assert!(from_text(text).is_none(), "{text:?}");
|
||||
}
|
||||
// Markup that doesn't parse, and markup past the read limit, paste nothing either.
|
||||
assert!(from_text("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"8\" height=\"8\"><rect").is_none());
|
||||
assert!(from_text("<svg width=\"8\" height=\"8\"><g></svg>").is_none());
|
||||
let mut huge = String::from("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"8\" height=\"8\">");
|
||||
huge.push_str(&" ".repeat(usize::try_from(MAX_CLIPBOARD_SVG_BYTES).unwrap()));
|
||||
huge.push_str("</svg>");
|
||||
assert!(from_text(&huge).is_none(), "markup past the limit isn't parsed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_clipboard_text_accepts_a_long_xml_preamble() {
|
||||
let padding = "x".repeat(8192);
|
||||
for prefix in [format!("<!--{padding}-->"), format!("<?xml version=\"1.0\"?>\n<!--{padding}-->"), format!("<?drawing {padding}?>")] {
|
||||
let text = format!("{prefix}{HALF_RED}");
|
||||
let img = from_text(&text).expect("SVG after the XML preamble");
|
||||
assert_eq!((img.0, img.1), (40, 20));
|
||||
assert_eq!(pixel(&img, 5, 10), [255, 0, 0, 255]);
|
||||
assert_eq!(pixel(&img, 30, 10), [0, 0, 0, 0]);
|
||||
}
|
||||
assert!(from_text(&format!("<!--{padding}<svg/>--><not-svg/>")).is_none());
|
||||
assert!(from_text(&format!("<!--{padding}--><svg><g></svg>")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawing_past_the_pixel_limit_pastes_scaled_down() {
|
||||
let wide = r##"<svg xmlns="http://www.w3.org/2000/svg" width="20000" height="10000"><rect width="20000" height="10000" fill="#0000ff"/></svg>"##;
|
||||
let img = from_text(wide).expect("scaled down, not dropped");
|
||||
let pixels = u64::from(img.0) * u64::from(img.1);
|
||||
assert!(pixels <= 24_000_000 && pixels > 12_000_000, "{} × {}", img.0, img.1);
|
||||
assert!(img.0.abs_diff(img.1 * 2) <= 2, "the proportions are kept: {} × {}", img.0, img.1);
|
||||
assert_eq!(pixel(&img, img.0 / 2, img.1 / 2), [0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
/// Copied files and an image come before SVG text, which is read only when neither is there.
|
||||
#[test]
|
||||
fn copied_files_and_images_come_before_svg_text() {
|
||||
let dir = temp("svg-order");
|
||||
let png = dir.join("red.png");
|
||||
std::fs::write(&png, red_png(3, 2)).unwrap();
|
||||
let blue = (2, 2, [0u8, 0, 255, 255].repeat(4));
|
||||
let mut text_read = false;
|
||||
let svg_text = |read: &mut bool| {
|
||||
*read = true;
|
||||
Ok(Some(COPIED_RECTANGLE.to_string()))
|
||||
};
|
||||
let got = clipboard_image(&mut text_read, |_| Ok(Some(vec![png.clone()])), |_| Ok(Some(blue.clone())), svg_text);
|
||||
assert_eq!(got.unwrap().map(|(w, h, _)| (w, h)), Some((3, 2)), "the copied file");
|
||||
let got = clipboard_image(&mut text_read, |_| Ok(None), |_| Ok(Some(blue.clone())), svg_text);
|
||||
assert_eq!(got.unwrap().map(|(w, h, _)| (w, h)), Some((2, 2)), "the image, not the SVG text");
|
||||
assert!(!text_read, "the text isn't read while there is a file or an image");
|
||||
// A copied file that isn't an image (an .svg) falls through to the image, then the text.
|
||||
let svg = dir.join("drawing.svg");
|
||||
std::fs::write(&svg, COPIED_RECTANGLE).unwrap();
|
||||
let got = clipboard_image(&mut (), |_| Ok(Some(vec![svg.clone()])), |_| Ok(None), |_| Ok(Some(COPIED_RECTANGLE.to_string())));
|
||||
assert_eq!(got.unwrap().map(|(w, h, _)| (w, h)), Some((121, 81)));
|
||||
}
|
||||
|
||||
/// A failed read, such as one of a busy clipboard, is the paste's error: the reads after it,
|
||||
/// the SVG text included, don't run.
|
||||
#[test]
|
||||
fn a_failed_clipboard_read_is_an_error_and_the_text_is_not_read() {
|
||||
fn busy<T>() -> Result<T, String> {
|
||||
Err("the clipboard is busy".to_string())
|
||||
}
|
||||
let mut text_read = false;
|
||||
let svg_text = |read: &mut bool| {
|
||||
*read = true;
|
||||
Ok(Some(COPIED_RECTANGLE.to_string()))
|
||||
};
|
||||
let got = clipboard_image(&mut text_read, |_| busy(), |_| Ok(None), svg_text);
|
||||
assert_eq!(got, Err("the clipboard is busy".to_string()), "the file list's error");
|
||||
let got = clipboard_image(&mut text_read, |_| Ok(None), |_| busy(), svg_text);
|
||||
assert_eq!(got, Err("the clipboard is busy".to_string()), "the image's error");
|
||||
assert!(!text_read, "the text isn't read after a failed read");
|
||||
let got = clipboard_image(&mut (), |_| Ok(None), |_| Ok(None), |_| busy());
|
||||
assert_eq!(got, Err("the clipboard is busy".to_string()), "the text's error");
|
||||
}
|
||||
|
||||
/// Only a clipboard with nothing in the format asked for reads as nothing to paste. A read
|
||||
/// that fails in any other way, such as a broken pipe on Wayland, is the paste's error, for
|
||||
/// the copied files and the text alike. Text that isn't UTF-8 reads as no SVG markup.
|
||||
#[test]
|
||||
fn only_missing_content_reads_as_nothing_to_paste() {
|
||||
use arboard::Error;
|
||||
let failures = || [Error::ClipboardOccupied, Error::ClipboardNotSupported, Error::Unknown { description: "broken pipe".to_string() }];
|
||||
assert_eq!(clipboard_read(Ok::<_, Error>(vec![PathBuf::from("a.png")])), Ok(Some(vec![PathBuf::from("a.png")])));
|
||||
assert_eq!(clipboard_read::<Vec<PathBuf>>(Err(Error::ContentNotAvailable)), Ok(None));
|
||||
for error in failures().into_iter().chain([Error::ConversionFailure]) {
|
||||
let message = error.to_string();
|
||||
assert_eq!(clipboard_read::<Vec<PathBuf>>(Err(error)), Err(message.clone()), "{message}");
|
||||
}
|
||||
assert_eq!(clipboard_text(Ok("words".to_string())), Ok(Some("words".to_string())));
|
||||
assert_eq!(clipboard_text(Err(Error::ContentNotAvailable)), Ok(None));
|
||||
assert_eq!(clipboard_text(Err(Error::ConversionFailure)), Ok(None), "text that isn't UTF-8");
|
||||
for error in failures() {
|
||||
let message = error.to_string();
|
||||
assert_eq!(clipboard_text(Err(error)), Err(message.clone()), "{message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit › Paste with SVG markup as the clipboard's only content adds a new layer that holds the
|
||||
/// drawing's art; other text is still a quiet "nothing to paste".
|
||||
#[test]
|
||||
fn paste_turns_svg_text_on_the_clipboard_into_a_layer() {
|
||||
let text: Rc<RefCell<String>> = Rc::new(RefCell::new(HALF_RED.to_string()));
|
||||
let clip = text.clone();
|
||||
let services = Services {
|
||||
clipboard_get_image: Some(Box::new(move || clipboard_image(&mut (), |_| Ok(None), |_| Ok(None), |_| Ok(Some(clip.borrow().clone()))))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
let ctx = egui::Context::default();
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
let layers = app.session.active().unwrap().doc.layers.len();
|
||||
let r = photocraft_ui_egui::menus::invoke(&mut app, &ctx, "edit.paste", json!({})).unwrap();
|
||||
assert_ne!(r["pasted"], json!(false), "{r}");
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), layers + 1);
|
||||
let surface = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
|
||||
let bounds = surface.content_bounds();
|
||||
assert_eq!((bounds.width(), bounds.height()), (20, 20), "the red half; the transparent half adds nothing");
|
||||
*text.borrow_mut() = "just words".to_string();
|
||||
let r = photocraft_ui_egui::menus::invoke(&mut app, &ctx, "edit.paste", json!({})).unwrap();
|
||||
assert_eq!(r["pasted"], json!(false));
|
||||
assert_eq!(app.ui.status, "Nothing to paste: the clipboard holds no image");
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1);
|
||||
}
|
||||
|
||||
/// #7582: a panic while reading the OS clipboard (in arboard, or decoding a copied file) is
|
||||
/// the paste's error in the status bar, not a crash: the document stays and the next paste
|
||||
/// works. The same for a panic while writing it.
|
||||
|
||||
@@ -2,3 +2,6 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
|
||||
@@ -377,6 +377,44 @@ fn doc_place_reads_through_the_read_root_and_never_outside_it() {
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
|
||||
/// A save or render never turns a file PhotoCraft doesn't write (source, settings, notes)
|
||||
/// into image bytes, whatever `format` says; images and new files are written as before (#9439).
|
||||
#[test]
|
||||
fn workspace_writes_refuse_to_replace_a_file_that_is_not_an_image() {
|
||||
let root = std::env::temp_dir().join(format!("pc-no-replace-foreign-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
let foreign = [("src/main.rs", "fn main() {}\n"), ("Cargo.toml", "[package]\n"), (".env", "TOKEN=x\n"), ("Makefile", "all:\n")];
|
||||
for (name, text) in foreign {
|
||||
std::fs::write(root.join(name), text).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("old.png"), b"not yet a png").unwrap();
|
||||
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
|
||||
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
|
||||
for (name, text) in foreign {
|
||||
let saved = h.handle("doc.save", json!({"path": name, "format": "png"})).unwrap_err().to_string();
|
||||
assert!(saved.contains("already exists"), "doc.save over {name}: {saved}");
|
||||
let rendered = h.handle("doc.render", json!({"path": name})).unwrap_err().to_string();
|
||||
assert!(rendered.contains("already exists"), "doc.render over {name}: {rendered}");
|
||||
assert_eq!(std::fs::read_to_string(root.join(name)).unwrap(), text, "{name} is untouched");
|
||||
}
|
||||
// An existing image is replaced, and a new file of any name is created.
|
||||
h.handle("doc.save", json!({"path": "old.png"})).unwrap();
|
||||
assert!(std::fs::read(root.join("old.png")).unwrap().starts_with(b"\x89PNG"));
|
||||
h.handle("doc.save", json!({"path": "fresh.txt", "format": "png"})).unwrap();
|
||||
assert!(std::fs::read(root.join("fresh.txt")).unwrap().starts_with(b"\x89PNG"));
|
||||
h.handle("doc.render", json!({"path": "old.png"})).unwrap();
|
||||
std::fs::write(root.join("OLD.TIF"), b"x").unwrap();
|
||||
h.handle("doc.save", json!({"path": "OLD.TIF"})).unwrap();
|
||||
// An image saved under a name without an image extension is recognised by its bytes.
|
||||
h.handle("doc.save", json!({"path": "out", "format": "png"})).unwrap();
|
||||
h.handle("doc.save", json!({"path": "out", "format": "png"})).unwrap();
|
||||
h.handle("doc.render", json!({"path": "fresh.txt"})).unwrap();
|
||||
// Release directory capabilities before cleanup on Windows.
|
||||
drop(h);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_replace_smart_object_reads_through_the_read_root_and_never_outside_it() {
|
||||
let base = std::env::temp_dir().join(format!("pc-doc-replace-so-{}", std::process::id()));
|
||||
|
||||
@@ -88,6 +88,22 @@ impl AuthorizedWorkspace {
|
||||
let relative = request_path(path, self.write.as_ref())?;
|
||||
let root = self.write.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: write authority is absent")))?;
|
||||
let leaf = relative.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
// The bytes are an image or document whatever the name says (`format` picks them), so
|
||||
// only a file of a kind PhotoCraft writes is replaced: never source, settings or notes
|
||||
// that share the root (`src/main.rs`, `.env`; #9439). A new file may have any name, and
|
||||
// an image saved under one (`out` with `format: png`) can be saved over again.
|
||||
let exists = root.dir.symlink_metadata(&relative).is_ok();
|
||||
// Only a regular file is ever opened below (its head here, its attributes on Unix): a
|
||||
// FIFO would block that open until a writer appears, and with it every other request.
|
||||
// Links are followed for this check; a dangling one falls through to the name rule.
|
||||
if exists && root.dir.metadata(&relative).is_ok_and(|m| !m.is_file()) {
|
||||
return Err(AutomationError::BadRequest(format!("automation write refused: `{path}` already exists and is not a regular file")));
|
||||
}
|
||||
if exists && !written_kind(&leaf) && !holds_image(&root.dir, &relative) {
|
||||
return Err(AutomationError::BadRequest(format!(
|
||||
"automation write refused: `{path}` already exists and is not an image or document file PhotoCraft writes; save to a new name"
|
||||
)));
|
||||
}
|
||||
let parent = relative.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf));
|
||||
let mut options = OpenOptions::new();
|
||||
@@ -124,6 +140,23 @@ impl AuthorizedWorkspace {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name`'s extension is one of the formats PhotoCraft saves or exports.
|
||||
fn written_kind(name: &str) -> bool {
|
||||
let Some((_, ext)) = name.rsplit_once('.') else { return false };
|
||||
let ext = ext.to_ascii_lowercase();
|
||||
["psd", "psb", "ora", "pdf", photocraft_format::EXTENSION].contains(&ext.as_str()) || photocraft_codecs::from_extension(&ext).is_some()
|
||||
}
|
||||
|
||||
/// Whether the file at `relative` starts like an image PhotoCraft reads (PSD/PSB or a codec's
|
||||
/// magic). Containers that also hold other things (zip, PDF) are judged by name only.
|
||||
fn holds_image(dir: &Dir, relative: &Path) -> bool {
|
||||
let mut head = [0u8; 64];
|
||||
let Ok(mut file) = dir.open(relative) else { return false };
|
||||
let Ok(n) = file.read(&mut head) else { return false };
|
||||
let head = &head[..n];
|
||||
head.starts_with(b"8BPS") || photocraft_codecs::detect(head).is_some()
|
||||
}
|
||||
|
||||
fn read_to_observed_length(reader: &mut impl Read, observed_length: u64) -> Result<Vec<u8>, AutomationError> {
|
||||
// Do not reserve from a potentially stale or attacker-controlled metadata length. Read in
|
||||
// chunks, and reject growth beyond the length observed on the opened file before appending.
|
||||
@@ -221,11 +254,14 @@ fn relative_path(raw: &str) -> Result<PathBuf, AutomationError> {
|
||||
Ok(PathBuf::from(raw))
|
||||
}
|
||||
|
||||
/// At least every name cap-std refuses to open on Windows (`COM0`–`COM9`, `COM¹`–`COM³`, the same
|
||||
/// for `LPT`, spaces before the extension ignored), so nothing written is unreadable (#9424).
|
||||
fn is_windows_device_name(component: &str) -> bool {
|
||||
let stem = component.split('.').next().unwrap_or(component).to_ascii_uppercase();
|
||||
let stem = component.split('.').next().unwrap_or(component).trim_end().to_ascii_uppercase();
|
||||
let port = |n: &str| matches!(n, "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³");
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$")
|
||||
|| stem.strip_prefix("COM").is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
|
||||
|| stem.strip_prefix("LPT").is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
|
||||
|| stem.strip_prefix("COM").is_some_and(port)
|
||||
|| stem.strip_prefix("LPT").is_some_and(port)
|
||||
}
|
||||
|
||||
fn path_error(path: &str, reason: &str) -> AutomationError {
|
||||
|
||||
@@ -122,6 +122,46 @@ fn write_replaces_atomically_and_leaves_no_temp_file() {
|
||||
assert!(temp_files(&inside).is_empty());
|
||||
}
|
||||
|
||||
/// A directory at the target is refused before anything is opened, even under an image name.
|
||||
#[test]
|
||||
fn write_refuses_a_directory_target_before_opening_it() {
|
||||
let (inside, _, workspace) = roots("dir-target");
|
||||
for name in ["adir", "adir.png"] {
|
||||
std::fs::create_dir_all(inside.join(name)).unwrap();
|
||||
let error = workspace.write(name, b"x").unwrap_err().to_string();
|
||||
assert!(error.contains("is not a regular file"), "{name}: {error}");
|
||||
assert!(inside.join(name).is_dir());
|
||||
}
|
||||
assert!(temp_files(&inside).is_empty());
|
||||
}
|
||||
|
||||
/// A FIFO at the target is refused at once: opening it to sniff its head or copy its
|
||||
/// attributes would block until a writer appears, stalling every other request.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_refuses_a_fifo_target_without_blocking() {
|
||||
let (inside, _, workspace) = roots("fifo-target");
|
||||
for name in ["pipe", "out.png"] {
|
||||
let made = std::process::Command::new("mkfifo").arg(inside.join(name)).status();
|
||||
if !made.is_ok_and(|s| s.success()) {
|
||||
return; // no mkfifo on this system: nothing to assert
|
||||
}
|
||||
}
|
||||
std::os::unix::fs::symlink("pipe", inside.join("link")).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
for name in ["pipe", "out.png", "link"] {
|
||||
let _ = tx.send((name, workspace.write(name, b"x").map_err(|e| e.to_string())));
|
||||
}
|
||||
});
|
||||
for _ in 0..3 {
|
||||
let (name, result) = rx.recv_timeout(std::time::Duration::from_secs(5)).expect("write blocked on a FIFO");
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.contains("is not a regular file"), "{name}: {error}");
|
||||
}
|
||||
assert!(temp_files(&inside).is_empty());
|
||||
}
|
||||
|
||||
/// #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"))]
|
||||
@@ -177,6 +217,32 @@ fn rejects_absolute_traversal_mixed_prefix_and_malformed_paths_without_panicking
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_device_name_cap_std_refuses_to_open_is_refused_before_any_write() {
|
||||
// cap-std refuses these when it opens a file on Windows, so a file written under one of
|
||||
// them could not be read back (#9424).
|
||||
let (inside, _, workspace) = roots("devices");
|
||||
std::fs::create_dir(inside.join("sub")).unwrap();
|
||||
for path in ["COM0.txt", "LPT0", "COM¹.png", "com².png", "COM³", "LPT¹.txt", "LPT².txt", "lpt³", "NUL .png", "Aux .txt", "sub/CON .psd", "COM¹/frame.png"]
|
||||
{
|
||||
let error = workspace.write(path, b"x").unwrap_err().to_string();
|
||||
assert!(error.contains("reserved device names"), "write {path:?}: {error}");
|
||||
let error = workspace.read(path).unwrap_err().to_string();
|
||||
assert!(error.contains("reserved device names"), "read {path:?}: {error}");
|
||||
}
|
||||
for dir in ["COM¹", "LPT0", "sub/PRN .x"] {
|
||||
let error = workspace.export_sequence(dir).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("reserved device names"), "export {dir:?}: {error}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&inside).unwrap().count(), 1, "only `sub` may exist");
|
||||
assert_eq!(std::fs::read_dir(inside.join("sub")).unwrap().count(), 0);
|
||||
// Look-alikes are ordinary names.
|
||||
for path in ["COM10.txt", "COM¹0.txt", "LPT00", "console.txt", "NULL.txt", "nul_.txt", " NUL.txt"] {
|
||||
workspace.write(path, b"x").unwrap_or_else(|e| panic!("write {path:?}: {e}"));
|
||||
assert_eq!(workspace.read(path).unwrap(), b"x", "{path:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_and_write_authority_are_separate() {
|
||||
let (inside, _, _) = roots("separate");
|
||||
|
||||
@@ -87,16 +87,50 @@ impl LutFile {
|
||||
}
|
||||
}
|
||||
|
||||
/// A LUT file format.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
Cube,
|
||||
ThreeDl,
|
||||
Look,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// The format [`parse`] reads `bytes` as: from `file_name`'s extension, else sniffed
|
||||
/// (XML is `.look`, anything else `.cube`).
|
||||
pub fn detect(file_name: &str, bytes: &[u8]) -> Self {
|
||||
let lower = file_name.to_ascii_lowercase();
|
||||
if lower.ends_with(".3dl") {
|
||||
Format::ThreeDl
|
||||
} else if lower.ends_with(".look") || decode_text(bytes).trim_start().starts_with('<') {
|
||||
Format::Look
|
||||
} else {
|
||||
Format::Cube
|
||||
}
|
||||
}
|
||||
|
||||
/// The file extension, without the dot.
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Format::Cube => "cube",
|
||||
Format::ThreeDl => "3dl",
|
||||
Format::Look => "look",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a LUT, picking the format from `file_name`'s extension (falls back to sniffing).
|
||||
pub fn parse(file_name: &str, bytes: &[u8]) -> Result<LutFile, LutError> {
|
||||
let lower = file_name.to_ascii_lowercase();
|
||||
parse_as(Format::detect(file_name, bytes), bytes)
|
||||
}
|
||||
|
||||
/// Parses a LUT in a known format.
|
||||
pub fn parse_as(format: Format, bytes: &[u8]) -> Result<LutFile, LutError> {
|
||||
let text = decode_text(bytes);
|
||||
if lower.ends_with(".3dl") {
|
||||
parse_3dl(&text)
|
||||
} else if lower.ends_with(".look") || text.trim_start().starts_with('<') {
|
||||
parse_look(&text)
|
||||
} else {
|
||||
parse_cube(&text)
|
||||
match format {
|
||||
Format::Cube => parse_cube(&text),
|
||||
Format::ThreeDl => parse_3dl(&text),
|
||||
Format::Look => parse_look(&text),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,6 +552,9 @@ DOMAIN_MAX 0.5 1 1
|
||||
let be: Vec<u8> = [0xFE, 0xFF].into_iter().chain(xml.encode_utf16().flat_map(u16::to_be_bytes)).collect();
|
||||
assert_eq!(parse("a.look", &le).unwrap().data, id.data);
|
||||
assert_eq!(parse("a.look", &be).unwrap().data, id.data);
|
||||
// Sniffed as XML through the byte-order mark too, so the recorded format is the one parsed.
|
||||
assert_eq!(Format::detect("a.lut", &le), Format::Look);
|
||||
assert_eq!(parse_as(Format::Look, &be).unwrap().data, id.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -570,6 +607,17 @@ DOMAIN_MAX 0.5 1 1
|
||||
assert!(parse_look(&text).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_detection_matches_parse() {
|
||||
assert_eq!(Format::detect("a.3DL", b"<look>"), Format::ThreeDl);
|
||||
assert_eq!(Format::detect("a.Look", b""), Format::Look);
|
||||
assert_eq!(Format::detect("a.cube", b" <look>"), Format::Look, "XML is sniffed as .look");
|
||||
assert_eq!(Format::detect("a.txt", b"LUT_3D_SIZE 2"), Format::Cube);
|
||||
assert_eq!(Format::ThreeDl.extension(), "3dl");
|
||||
let text = write_cube(&LutFile::identity(2));
|
||||
assert_eq!(parse_as(Format::Cube, text.as_bytes()), parse("a.cube", text.as_bytes()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_rejected() {
|
||||
assert!(parse_cube("LUT_3D_SIZE 2\n0 0 0\n").is_err());
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
cc a795914e4492bef1e662315563d48d54f09030c48e046df95329465c86dde4b4 # shrinks to s = Spec { w: 4, h: 6, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [1.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 1.0105422, 0.0, 0.7] }), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }] }
|
||||
cc e14b02709a87770da2637c28678c4fa761b5628f402e0c6d60fe66f1aa4d4bfa # shrinks to s = Spec { w: 3, h: 2, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Group { children: [Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 0.3, 0.0, 0.7] }), blend: Dissolve, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.8972109, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] }
|
||||
cc 4eeba1f8db987228717b932b82d043d61e82fd897a33ea9e2ce68d6c61d7f491 # shrinks to s = Spec { w: 3, h: 5, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 0.026626235, clipped: false, visible: true, mask: None }, Pixels { kind: Adjust(Invert), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] }
|
||||
cc 33fb5c528e97f31669a82a4634930230f903c97e58ce59fc88fd733ac097089c # shrinks to s = Spec { w: 6, h: 4, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 5, y1: 3 }, [0.0, 1.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }, Group { children: [Pixels { kind: Solid, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 1.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: VividLight, opacity: 1.0, fill: 1.0, mask: None }, Pixels { kind: Text, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: true, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] }
|
||||
|
||||
@@ -198,7 +198,8 @@ fn color_lookup_with_an_overflowing_stored_size_is_the_identity() {
|
||||
// A stored document's size is untrusted: (2²²)³ × 3 overflows, which once wrapped past
|
||||
// the table-length guard and indexed out of bounds.
|
||||
for size in [1 << 22, u32::MAX] {
|
||||
let a = Adjustment::ColorLookup { name: "x".into(), lut: Some(std::sync::Arc::new(vec![0.5; 24])), size, tetrahedral: false, dither: false };
|
||||
let a =
|
||||
Adjustment::ColorLookup { name: "x".into(), lut: Some(std::sync::Arc::new(vec![0.5; 24])), size, tetrahedral: false, dither: false, source: None };
|
||||
let mut b = ramp();
|
||||
apply(&a, &mut b);
|
||||
assert_eq!(b.px, ramp().px, "size {size}");
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
//! composites onto the result in its own mode, kept inside the base's shape (its alpha with
|
||||
//! masks, times its opacity).
|
||||
//! - **Transparency Shapes Layer off.** The layer's effects and knockout cover the whole layer
|
||||
//! (its masks still apply) instead of following its transparency.
|
||||
//! (its masks still apply) instead of following its transparency. In the special eight
|
||||
//! ([`crate::psblend::fill_is_special`]) the layer's own transparency then blends as Fill does
|
||||
//! (`blend_layer_into` in `composite.rs`; measured in Photoshop, #5692).
|
||||
//! - **Layer / Vector Mask Hides Effects.** The effects are built from the layer without that
|
||||
//! mask, and the mask is applied to the finished layer and effects.
|
||||
//!
|
||||
@@ -75,7 +77,13 @@ pub fn advanced_active(layer: &Layer, clipped: &[Layer]) -> bool {
|
||||
return false;
|
||||
}
|
||||
let fx = effects::has_effects(layer);
|
||||
a.knockout != Knockout::None || clips_individually(layer, clipped) || (a.blend_interior && fx) || (!a.transparency_shapes && fx) || hides_effects(layer)
|
||||
// Transparency Shapes Layer off changes the special eight's blend itself (#5692).
|
||||
let shapeless_blend = crate::psblend::fill_is_special(layer.blend) && !matches!(layer.content, LayerContent::Adjustment(_));
|
||||
a.knockout != Knockout::None
|
||||
|| clips_individually(layer, clipped)
|
||||
|| (a.blend_interior && fx)
|
||||
|| (!a.transparency_shapes && (fx || shapeless_blend))
|
||||
|| hides_effects(layer)
|
||||
}
|
||||
|
||||
/// Whether the layer's effects (or knockout) cover the whole layer: Transparency Shapes Layer
|
||||
|
||||
@@ -555,3 +555,157 @@ fn deep_knockout_reveals_a_localized_background() {
|
||||
check(&d, 2, 2, [0.0; 4], "an unlocked layer named Background");
|
||||
}
|
||||
}
|
||||
|
||||
/// Transparency Shapes Layer off in the special eight: the layer's own transparency blends as
|
||||
/// Fill does (#5692). Measured in Photoshop: Background (90, 110, 130), a layer (200, 150, 80) at
|
||||
/// alpha 30 % (77) and 70 % (179) in columns 0–5 and 6–11, read from the flattened 8-bit PNG.
|
||||
#[test]
|
||||
fn transparency_shapes_off_blends_alpha_as_fill() {
|
||||
const A30: f32 = 77.0 / 255.0;
|
||||
const A70: f32 = 179.0 / 255.0;
|
||||
let c8 = |c: [u8; 3]| [f32::from(c[0]) / 255.0, f32::from(c[1]) / 255.0, f32::from(c[2]) / 255.0];
|
||||
let px = |c: [u8; 3], a: f32| {
|
||||
let c = c8(c);
|
||||
[c[0], c[1], c[2], a]
|
||||
};
|
||||
let make = |depth: SampleType, mode: BlendMode, opacity: f32, fill: f32, tsl: bool| {
|
||||
let b = c8([90, 110, 130]);
|
||||
let mut d = Document::with_background("t", Size::new(12, 8), ColorMode::Rgb, depth, Color::rgb(b[0], b[1], b[2]));
|
||||
let mut l = solid(&d, "a30", Rect::new(0, 0, 6, 8), px([200, 150, 80], A30));
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(6, 0, 12, 8), &photocraft_raster::from_rgba(&d.pixel_format(), px([200, 150, 80], A70)));
|
||||
l.blend = mode;
|
||||
l.opacity = opacity;
|
||||
l.fill_opacity = fill;
|
||||
l.advanced.transparency_shapes = tsl;
|
||||
d.layers.push(l);
|
||||
d
|
||||
};
|
||||
let (o53, f50) = (135.0 / 255.0, 128.0 / 255.0);
|
||||
// (mode, opacity, fill, off at 30 %, off at 70 %, on at 30 %, on at 70 %)
|
||||
type Rgb = [u8; 3];
|
||||
let measured: [(BlendMode, f32, f32, Rgb, Rgb, Rgb, Rgb); 7] = [
|
||||
(BlendMode::ColorDodge, o53, 1.0, [105, 123, 137], [148, 151, 150], [117, 133, 139], [151, 164, 152]),
|
||||
(BlendMode::ColorBurn, o53, 1.0, [84, 99, 113], [74, 79, 69], [83, 94, 109], [73, 72, 82]),
|
||||
(BlendMode::LinearDodge, o53, 1.0, [122, 134, 143], [164, 166, 160], [117, 133, 143], [151, 164, 160]),
|
||||
(BlendMode::HardMix, o53, 1.0, [98, 111, 119], [133, 117, 75], [117, 133, 109], [151, 164, 82]),
|
||||
(BlendMode::Difference, o53, 1.0, [58, 86, 117], [69, 54, 100], [93, 99, 117], [97, 84, 100]),
|
||||
(BlendMode::ColorDodge, 1.0, f50, [102, 121, 136], [125, 139, 146], [108, 124, 137], [131, 142, 147]),
|
||||
(BlendMode::Difference, 1.0, f50, [59, 87, 118], [19, 57, 102], [66, 87, 118], [34, 57, 102]),
|
||||
];
|
||||
for depth in DEPTHS {
|
||||
for (mode, opacity, fill, off30, off70, on30, on70) in measured {
|
||||
// A 32-bit document's Linear Dodge doesn't clip at 1 (psblend::HDR).
|
||||
if depth == SampleType::F32 && mode == BlendMode::LinearDodge {
|
||||
continue;
|
||||
}
|
||||
let what = format!("{mode:?} opacity {opacity} fill {fill}");
|
||||
let off = make(depth, mode, opacity, fill, false);
|
||||
check(&off, 2, 4, px(off30, 1.0), &format!("{what}, off, alpha 30 %"));
|
||||
check(&off, 9, 4, px(off70, 1.0), &format!("{what}, off, alpha 70 %"));
|
||||
let on = make(depth, mode, opacity, fill, true);
|
||||
check(&on, 2, 4, px(on30, 1.0), &format!("{what}, on, alpha 30 %"));
|
||||
check(&on, 9, 4, px(on70, 1.0), &format!("{what}, on, alpha 70 %"));
|
||||
assert!(advanced_active(&off.layers[1], &[]), "{what}: the GPU leaves it to the CPU");
|
||||
}
|
||||
// Modes outside the special eight look the same either way, and stay on the plain path.
|
||||
for mode in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Overlay] {
|
||||
let (off, on) = (make(depth, mode, o53, f50, false), make(depth, mode, o53, f50, true));
|
||||
assert_eq!(render(&off, off.bounds()).px, render(&on, on.bounds()).px, "{mode:?}");
|
||||
assert!(!advanced_active(&off.layers[1], &[]), "{mode:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mask is not the layer's transparency: Photoshop draws an opaque layer under a 30 % mask the
|
||||
/// same with the option on or off (measured: Color Dodge, Hard Mix, Difference and the rest of
|
||||
/// the special eight, every Opacity and Fill tried). Over a half-transparent backdrop and for an
|
||||
/// isolated group the alpha still blends as Fill (measured: Color Dodge at 53 %).
|
||||
#[test]
|
||||
fn transparency_shapes_off_ignores_masks_and_reaches_groups() {
|
||||
let c8 = |c: [u8; 3]| [f32::from(c[0]) / 255.0, f32::from(c[1]) / 255.0, f32::from(c[2]) / 255.0];
|
||||
let px = |c: [u8; 3], a: f32| {
|
||||
let c = c8(c);
|
||||
[c[0], c[1], c[2], a]
|
||||
};
|
||||
let o53 = 135.0 / 255.0;
|
||||
for depth in DEPTHS {
|
||||
for mode in [BlendMode::ColorDodge, BlendMode::HardMix, BlendMode::Difference] {
|
||||
let render_with = |tsl: bool| {
|
||||
let b = c8([90, 110, 130]);
|
||||
let mut d = Document::with_background("t", Size::new(12, 8), ColorMode::Rgb, depth, Color::rgb(b[0], b[1], b[2]));
|
||||
let mut l = solid(&d, "l", full(&d), px([200, 150, 80], 1.0));
|
||||
l.blend = mode;
|
||||
l.opacity = o53;
|
||||
l.mask = Some(LayerMask { surface: photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[77.0 / 255.0]), ..LayerMask::reveal_all() });
|
||||
l.advanced.transparency_shapes = tsl;
|
||||
d.layers.push(l);
|
||||
render(&d, d.bounds()).px
|
||||
};
|
||||
let (off, on) = (render_with(false), render_with(true));
|
||||
assert!(off.iter().zip(&on).all(|(a, b)| close(*a, *b)), "{mode:?} ({depth:?}): a mask isn't the layer's transparency");
|
||||
}
|
||||
// Color Dodge at 53 % over a backdrop of alpha 50 % (128): (118, 127, 129, 148) at
|
||||
// alpha 30 %, (162, 151, 131, 175) at 70 %.
|
||||
let mut d = doc(depth, false);
|
||||
d.layers.push(solid(&d, "under", full(&d), px([90, 110, 130], 128.0 / 255.0)));
|
||||
let mut l = solid(&d, "a30", Rect::new(0, 0, 6, 8), px([200, 150, 80], 77.0 / 255.0));
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(6, 0, 12, 8), &photocraft_raster::from_rgba(&d.pixel_format(), px([200, 150, 80], 179.0 / 255.0)));
|
||||
l.blend = BlendMode::ColorDodge;
|
||||
l.opacity = o53;
|
||||
l.advanced.transparency_shapes = false;
|
||||
d.layers.push(l.clone());
|
||||
check(&d, 2, 4, px([118, 127, 129], 148.0 / 255.0), "half-transparent backdrop, alpha 30 %");
|
||||
check(&d, 9, 4, px([162, 151, 131], 175.0 / 255.0), "half-transparent backdrop, alpha 70 %");
|
||||
// An isolated Color Dodge group at 53 % holding the Normal layer: the group's alpha blends
|
||||
// as Fill, as a layer's does.
|
||||
let b = c8([90, 110, 130]);
|
||||
let mut d = Document::with_background("t", Size::new(12, 8), ColorMode::Rgb, depth, Color::rgb(b[0], b[1], b[2]));
|
||||
l.blend = BlendMode::Normal;
|
||||
l.opacity = 1.0;
|
||||
l.advanced.transparency_shapes = true;
|
||||
let mut g = Layer::group("g", vec![l]);
|
||||
g.blend = BlendMode::ColorDodge;
|
||||
g.opacity = o53;
|
||||
g.advanced.transparency_shapes = false;
|
||||
d.layers.push(g);
|
||||
check(&d, 2, 4, px([105, 123, 137], 1.0), "group, alpha 30 %");
|
||||
check(&d, 9, 4, px([148, 151, 150], 1.0), "group, alpha 70 %");
|
||||
}
|
||||
}
|
||||
|
||||
/// A clipped knockout in a Transparency Shapes Layer off base of the special eight: the knockout
|
||||
/// stops at the base's own content (Blend Clipped Layers as Group), so it gives back the base's
|
||||
/// colour without touching its alpha, and the base's transparency still blends as Fill under a
|
||||
/// feathered vector mask. Where the fill-0 knocker covers an earlier clipped layer the composite
|
||||
/// is the base's alone.
|
||||
#[test]
|
||||
fn transparency_shapes_off_keeps_the_base_alpha_under_a_clipped_knockout() {
|
||||
let square = photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polygon(&[(2.0, 1.0), (10.0, 1.0), (10.0, 7.0), (2.0, 7.0)])]);
|
||||
for depth in DEPTHS {
|
||||
for k in [Knockout::Shallow, Knockout::Deep] {
|
||||
let mut d = Document::with_background("t", Size::new(12, 8), ColorMode::Rgb, depth, Color::rgb(90.0 / 255.0, 110.0 / 255.0, 130.0 / 255.0));
|
||||
let mut base = solid(&d, "base", Rect::new(0, 0, 3, 8), [0.78, 0.59, 0.31, 0.3]);
|
||||
base.surface_mut().unwrap().fill_rect(Rect::new(3, 0, 12, 8), &photocraft_raster::from_rgba(&d.pixel_format(), [0.78, 0.59, 0.31, 0.7]));
|
||||
base.blend = BlendMode::ColorDodge;
|
||||
base.opacity = 0.53;
|
||||
base.advanced.transparency_shapes = false;
|
||||
base.vector_mask = Some(photocraft_doc::vector::VectorMask { feather: 1.5, ..photocraft_doc::vector::VectorMask::new(square.clone()) });
|
||||
d.layers.push(base);
|
||||
let alone = render(&d, d.bounds()).px;
|
||||
let mut red = solid(&d, "red", full(&d), [1.0, 0.0, 0.0, 0.5]);
|
||||
red.clipped = true;
|
||||
let mut knock = knocker(&d, k);
|
||||
knock.clipped = true;
|
||||
d.layers.extend([red, knock]);
|
||||
let got = render(&d, d.bounds()).px;
|
||||
let what = format!("{k:?} ({depth:?})");
|
||||
for (i, (g, a)) in got.iter().zip(&alone).enumerate() {
|
||||
if i % 12 < 6 {
|
||||
assert!(close(*g, *a), "{what}: knocked out at {i}: {g:?}, the base alone {a:?}");
|
||||
}
|
||||
}
|
||||
assert!(!close(got[6 * 12 + 8], alone[6 * 12 + 8]), "{what}: the red shows outside the knocker");
|
||||
check(&d, 4, 3, alone[3 * 12 + 4], &what);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,8 +105,10 @@ fn over_text(b: Cmyka, s: Cmyka, opacity: f32) -> Cmyka {
|
||||
let dec = |v: f32| 1.0 - photocraft_color::convert::linear_to_srgb(v.max(0.0).powf(gamma));
|
||||
let mut o = [0.0; 5];
|
||||
for i in 0..4 {
|
||||
// Mixing an ink with itself (or with nothing) leaves it exactly as it is.
|
||||
o[i] = if b[4] <= 0.0 || b[i] == s[i] { s[i] } else { dec((1.0 - sa) * b[4] / a * enc(b[i]) + sa / a * enc(s[i])) };
|
||||
// Mixing an ink with itself, with nothing, or over a backdrop it covers fully leaves it
|
||||
// exactly as it is (the round trip through the gamma space can move it by a rounding
|
||||
// step, which a mode that jumps at no ink or full ink turns into a whole ink).
|
||||
o[i] = if b[4] <= 0.0 || sa >= 1.0 || b[i] == s[i] { s[i] } else { dec((1.0 - sa) * b[4] / a * enc(b[i]) + sa / a * enc(s[i])) };
|
||||
}
|
||||
o[4] = a;
|
||||
o
|
||||
@@ -394,6 +396,64 @@ fn isolated_groups_keep_the_special_fill_rule() {
|
||||
}
|
||||
}
|
||||
|
||||
/// White type clipped onto an isolated Vivid Light group over full magenta (#9688): the glyph
|
||||
/// covers its pixel, so the group shows exactly no ink there, and Vivid Light with no ink (full
|
||||
/// brightness) gives no ink whatever the backdrop. Mixing the covered pixel through the type
|
||||
/// gamma would leave a rounding step of ink, which Vivid Light turns into full magenta.
|
||||
#[test]
|
||||
fn type_clipped_to_a_vivid_light_group_covers_exactly() {
|
||||
let mut inner = Layer::new("f", LayerContent::Fill(photocraft_doc::Fill::Solid(cmyk_color([0.0, 1.0, 0.0, 0.0], 1.0))));
|
||||
inner.blend = BlendMode::Normal;
|
||||
let mut vivid = Layer::group("vivid", vec![inner]);
|
||||
vivid.blend = BlendMode::VividLight;
|
||||
let mut glyph = text_layer(&[(Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0])]);
|
||||
glyph.clipped = true;
|
||||
let outer = Layer::group("outer", vec![layer(&[(Rect::new(0, 0, 5, 3), [0.0, 1.0, 0.0, 0.0, 1.0])]), vivid, glyph]);
|
||||
let mut d = doc(6, 4);
|
||||
d.layers.push(outer);
|
||||
assert!(supports(&d));
|
||||
let got = render_cmyk(&d, d.bounds()).unwrap_or_else(|| panic!("supported"));
|
||||
assert_eq!(got.px[0], [0.0, 0.0, 0.0, 0.0, 1.0], "white glyph in Vivid Light");
|
||||
// Magenta in Vivid Light onto magenta stays magenta.
|
||||
assert_eq!(got.px[1], [0.0, 1.0, 0.0, 0.0, 1.0], "beside the glyph");
|
||||
assert_matches_reference(&d);
|
||||
}
|
||||
|
||||
/// The same structure in the neighbouring modes, with type or pixels clipped (fully covering and
|
||||
/// at a partly covered edge), onto inks that sit at the jumps of the modes that have them.
|
||||
#[test]
|
||||
fn layers_clipped_to_isolated_groups_match_the_reference_in_every_mode() {
|
||||
use BlendMode as B;
|
||||
for mode in [B::VividLight, B::LinearLight, B::PinLight, B::HardMix, B::ColorDodge, B::ColorBurn, B::Normal, B::Multiply, B::Screen] {
|
||||
for text in [false, true] {
|
||||
for (backdrop, ink) in [([0.0, 1.0, 0.0, 0.0], [0.0; 4]), ([1.0, 0.0, 1.0, 1.0], [1.0; 4]), ([0.0, 1.0, 0.0, 0.0], [0.0, 0.3, 1.0, 0.0])] {
|
||||
let mut inner = Layer::new("f", LayerContent::Fill(photocraft_doc::Fill::Solid(cmyk_color(backdrop, 1.0))));
|
||||
inner.blend = BlendMode::Normal;
|
||||
let mut group = Layer::group("g", vec![inner]);
|
||||
group.blend = mode;
|
||||
let [c, m, y, k] = ink;
|
||||
let rects = [(Rect::new(0, 0, 2, 2), [c, m, y, k, 1.0]), (Rect::new(2, 0, 3, 2), [c, m, y, k, 0.5])];
|
||||
let mut clip = if text { text_layer(&rects) } else { layer(&rects) };
|
||||
clip.clipped = true;
|
||||
let [bc, bm, by, bk] = backdrop;
|
||||
let outer = Layer::group("outer", vec![layer(&[(Rect::new(0, 0, 4, 2), [bc, bm, by, bk, 1.0])]), group, clip]);
|
||||
let mut d = doc(4, 3);
|
||||
d.layers.push(outer);
|
||||
assert!(supports(&d), "{mode:?}");
|
||||
assert_matches_reference(&d);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Onto a pass-through group, clipped layers take the RGB compositor's isolated rendering.
|
||||
let mut pass = Layer::group("pass", vec![layer(&[(Rect::new(0, 0, 2, 2), [0.0, 1.0, 0.0, 0.0, 1.0])])]);
|
||||
pass.blend = BlendMode::PassThrough;
|
||||
let mut clip = text_layer(&[(Rect::new(0, 0, 1, 1), [0.0; 5])]);
|
||||
clip.clipped = true;
|
||||
let mut d = doc(2, 2);
|
||||
d.layers.extend([pass, clip]);
|
||||
assert!(!supports(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_documents_fall_back() {
|
||||
let simple = || {
|
||||
|
||||
@@ -364,6 +364,10 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
|
||||
};
|
||||
}
|
||||
}
|
||||
// A Frame's stroke over the frame's content, at the group's opacity.
|
||||
if let Some(stroke) = shape_split::frame_stroke(layer, cx.canvas, cx.depth) {
|
||||
blend_into(backdrop, &surface_to_buffer(&stroke, rect), BlendMode::Normal, opacity);
|
||||
}
|
||||
// Layers clipped to a pass-through group sit atop the group's
|
||||
// isolated rendering; their effect (isolated result with vs. without
|
||||
// them, each placed over the original backdrop) is added to the
|
||||
@@ -443,7 +447,7 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
|
||||
*p = psblend::composite(BlendMode::Normal, *p, *s, 1.0);
|
||||
p[3] *= mask_k(&mask, i);
|
||||
}
|
||||
blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0);
|
||||
blend_layer_into(backdrop, &content, layer, cx, 1.0);
|
||||
return;
|
||||
}
|
||||
if !matches!(layer.content, LayerContent::Group(_) | LayerContent::Fill(_) | LayerContent::Adjustment(_))
|
||||
@@ -467,14 +471,17 @@ pub(crate) fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop:
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The row-at-a-time fast path blends like `blend_into_fill`; Transparency Shapes Layer off
|
||||
// with one of the special eight needs `blend_layer_into`'s per-pixel mask split.
|
||||
if clipped.is_empty()
|
||||
&& (layer.advanced.transparency_shapes || !psblend::fill_is_special(layer.blend))
|
||||
&& let Some(surf) = pixel_surface(layer)
|
||||
{
|
||||
return composite_pixel_layer(layer, surf, backdrop, cx);
|
||||
}
|
||||
let Some(mut content) = render_content(layer, rect, cx) else { return };
|
||||
advanced::composite_clipped(clipped, &mut content, cx);
|
||||
blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, text_gamma(layer));
|
||||
blend_layer_into(backdrop, &content, layer, cx, text_gamma(layer));
|
||||
recycle_buffer(content);
|
||||
}
|
||||
|
||||
@@ -626,10 +633,34 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
}
|
||||
|
||||
/// Blend an isolated layer buffer into the backdrop.
|
||||
fn blend_into(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32) {
|
||||
pub(crate) fn blend_into(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32) {
|
||||
blend_into_g(backdrop, src, mode, opacity, 1.0);
|
||||
}
|
||||
|
||||
/// `layer`'s masked content `src` blended into the backdrop with its own mode, Opacity and Fill
|
||||
/// ([`blend_into_fill`]). With Transparency Shapes Layer off, the special eight
|
||||
/// ([`psblend::fill_is_special`]) take the layer's own transparency (its content's alpha, not its
|
||||
/// masks) inside the blend, as Fill: Photoshop composites a pixel of alpha `a` at Fill `f` as
|
||||
/// an opaque one at Fill `a × f`, covering the backdrop with Opacity × masks only. Other modes
|
||||
/// look the same either way (#5692).
|
||||
fn blend_layer_into(backdrop: &mut Buffer, src: &Buffer, layer: &Layer, cx: &Ctx, gamma: f32) {
|
||||
let (mode, opacity, fill) = (layer.blend, layer.opacity, layer.fill_opacity);
|
||||
if layer.advanced.transparency_shapes || !psblend::fill_is_special(mode) {
|
||||
return blend_into_fill(backdrop, src, mode, opacity, fill, gamma);
|
||||
}
|
||||
let mv = mask_vals(layer, backdrop.rect, cx);
|
||||
let fill = fill.clamp(0.0, 1.0);
|
||||
for (i, (b, s)) in backdrop.px.iter_mut().zip(&src.px).enumerate() {
|
||||
// `src` carries alpha × masks; split them again.
|
||||
let m = mv.as_ref().map_or(1.0, |v| v.get(i).copied().unwrap_or(1.0));
|
||||
if s[3] <= 0.0 || m <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let alpha = (s[3] / m).clamp(0.0, 1.0);
|
||||
*b = psblend::composite_fill(mode, *b, [s[0], s[1], s[2], m], opacity, fill * alpha, gamma);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`blend_into_g`] for a layer's own `opacity` and `fill`: Fill is part of the blend for
|
||||
/// Photoshop's special eight ([`psblend::composite_fill`]), plain coverage otherwise.
|
||||
pub(crate) fn blend_into_fill(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, fill: f32, gamma: f32) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::composite::{adjustment_quantum, composite_stack};
|
||||
use super::composite::{adjustment_quantum, blend_into, composite_stack};
|
||||
use super::ctx::Ctx;
|
||||
use super::extents::{effect_shape, effect_source_bounds, mask_vals, shapeless_stroke_bounds, transparent_outside};
|
||||
use super::fx_cache::effect_maps;
|
||||
@@ -94,6 +94,10 @@ pub(crate) fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Buff
|
||||
p[3] *= k;
|
||||
}
|
||||
}
|
||||
// A Frame's stroke lies over its masked content; the frame doesn't clip it.
|
||||
if let Some(stroke) = shape_split::frame_stroke(layer, cx.canvas, cx.depth) {
|
||||
blend_into(&mut buf, &surface_to_buffer(&stroke, rect), BlendMode::Normal, 1.0);
|
||||
}
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
|
||||
}
|
||||
match &layer.content {
|
||||
LayerContent::Group(g) if g.artboard.is_some() => g.artboard.as_ref().map_or(Rect::EMPTY, |a| a.rect),
|
||||
LayerContent::Group(g) => g.children.iter().filter(|c| c.visible).fold(Rect::EMPTY, |acc, c| {
|
||||
LayerContent::Group(g) => g.children.iter().filter(|c| c.visible).fold(frame_stroke_bounds(layer, canvas), |acc, c| {
|
||||
let b = layer_bounds(c, canvas);
|
||||
if b.is_empty() {
|
||||
acc
|
||||
@@ -47,23 +47,36 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a Photoshop Frame's stroke ([`shape_split::frame_shape`]) draws, within `canvas`; empty
|
||||
/// for other layers. Taken from the compiled stroke the renderer draws, not the path grown by the
|
||||
/// stroke width: a mitered corner reaches up to `miter_limit` widths past the path, and a frame
|
||||
/// has no pixels of its own that would cover it.
|
||||
fn frame_stroke_bounds(layer: &Layer, canvas: Rect) -> Rect {
|
||||
shape_split::frame_shape(layer)
|
||||
.and_then(|sh| photocraft_vector::CompiledShape::new(&sh, photocraft_vector::DEFAULT_TOLERANCE, canvas).bounds())
|
||||
.map_or(Rect::EMPTY, |r| r.intersect(&canvas))
|
||||
}
|
||||
|
||||
/// Where a layer's effects take their shape from: [`layer_bounds`], except that a group's
|
||||
/// children count with their own effects (a child's outside stroke shapes the group's drop
|
||||
/// shadow and must not be cut off at the child's pixels, #3274).
|
||||
pub fn effect_source_bounds(layer: &Layer, canvas: Rect) -> Rect {
|
||||
match &layer.content {
|
||||
LayerContent::Group(g) if g.artboard.is_none() && !advanced::shapeless(layer) => g.children.iter().filter(|c| c.visible).fold(Rect::EMPTY, |acc, c| {
|
||||
let m = if effects::has_effects(c) { effects::margin(c) } else { 0 };
|
||||
let b = effect_source_bounds(c, canvas);
|
||||
let b = if b.is_empty() { b } else { b.inflate(m) };
|
||||
if b.is_empty() {
|
||||
acc
|
||||
} else if acc.is_empty() {
|
||||
b
|
||||
} else {
|
||||
acc.union(&b)
|
||||
}
|
||||
}),
|
||||
// A Frame's stroke is part of the group's content its effects are built from.
|
||||
LayerContent::Group(g) if g.artboard.is_none() && !advanced::shapeless(layer) => {
|
||||
g.children.iter().filter(|c| c.visible).fold(frame_stroke_bounds(layer, canvas), |acc, c| {
|
||||
let m = if effects::has_effects(c) { effects::margin(c) } else { 0 };
|
||||
let b = effect_source_bounds(c, canvas);
|
||||
let b = if b.is_empty() { b } else { b.inflate(m) };
|
||||
if b.is_empty() {
|
||||
acc
|
||||
} else if acc.is_empty() {
|
||||
b
|
||||
} else {
|
||||
acc.union(&b)
|
||||
}
|
||||
})
|
||||
}
|
||||
_ => layer_bounds(layer, canvas),
|
||||
}
|
||||
}
|
||||
@@ -343,7 +356,7 @@ pub fn composite_bounds(layer: &Layer, canvas: Rect) -> Option<Rect> {
|
||||
}
|
||||
let own = match &layer.content {
|
||||
LayerContent::Group(g) if g.artboard.is_none() => {
|
||||
let mut acc = Rect::EMPTY;
|
||||
let mut acc = frame_stroke_bounds(layer, canvas);
|
||||
for c in g.children.iter().filter(|c| c.visible) {
|
||||
let b = composite_bounds(c, canvas)?;
|
||||
if !b.is_empty() {
|
||||
|
||||
@@ -44,7 +44,7 @@ pub(crate) fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::
|
||||
format!("{:?}", c.blend).hash(h);
|
||||
layer_identity(c, h, pin);
|
||||
}
|
||||
format!("{:?}", g.artboard).hash(h);
|
||||
format!("{:?}{:?}", g.artboard, g.frame_stroke).hash(h);
|
||||
}
|
||||
LayerContent::Fill(f) => {
|
||||
format!("{f:?}").hash(h);
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use photocraft_color::{PixelFormat, SampleType};
|
||||
use photocraft_doc::vector::ShapeLayer;
|
||||
use photocraft_doc::{Layer, LayerContent};
|
||||
use photocraft_geom::{Rect, TileCoord};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
@@ -97,6 +98,22 @@ pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surfac
|
||||
Some(parts)
|
||||
}
|
||||
|
||||
/// The stroke-only shape a Photoshop Frame's stroke draws: `Group::frame_stroke` along the
|
||||
/// group's enabled vector mask (the frame outline). `None` for other layers and for a frame
|
||||
/// without a visible stroke or a vector mask.
|
||||
pub fn frame_shape(layer: &Layer) -> Option<ShapeLayer> {
|
||||
let LayerContent::Group(g) = &layer.content else { return None };
|
||||
let stroke = g.frame_stroke.as_ref().filter(|s| s.width.is_finite() && s.width > 0.0 && s.opacity > 0.0)?;
|
||||
let vm = layer.vector_mask.as_ref().filter(|m| m.enabled && !m.path.is_empty())?;
|
||||
Some(ShapeLayer { path: vm.path.clone(), fill: None, stroke: Some(stroke.clone()), ..ShapeLayer::default() })
|
||||
}
|
||||
|
||||
/// A Photoshop Frame's stroke ([`frame_shape`]) rendered over `canvas` in RGBA at `depth`, from
|
||||
/// the same cache as the shape strokes. It goes above the frame's (masked) content.
|
||||
pub fn frame_stroke(layer: &Layer, canvas: Rect, depth: SampleType) -> Option<Surface> {
|
||||
split(&frame_shape(layer)?, canvas, depth).map(|(_, stroke)| stroke)
|
||||
}
|
||||
|
||||
/// Fits the stroke part to the shape's own pixels: where they reach past the fill, the stroke
|
||||
/// covers what the fill doesn't (`(pixels - fill) / (1 - fill)`), in the pixels' colour where we
|
||||
/// drew no stroke, and elsewhere in the colour that, drawn over the fill, gives back the pixels'
|
||||
|
||||
@@ -1358,6 +1358,46 @@ fn group_effects_reach_past_children_effects() {
|
||||
assert!(close4(px(&d, 24, 43), [1.0; 4]), "nothing past the shadow: {:?}", px(&d, 24, 43));
|
||||
}
|
||||
|
||||
/// An empty Photoshop Frame with an outside stroke along `points` (#4381).
|
||||
fn stroked_frame(points: &[(f64, f64)], width: f32) -> Layer {
|
||||
use photocraft_doc::vector::{Path, ShapeStroke, StrokeAlign, Subpath, VectorMask};
|
||||
let mut l = Layer::group("Frame 1", vec![]);
|
||||
l.vector_mask = Some(VectorMask::new(Path::new(vec![Subpath::polygon(points)])));
|
||||
if let LayerContent::Group(g) = &mut l.content {
|
||||
g.frame_stroke = Some(ShapeStroke { width, align: StrokeAlign::Outside, paint: Fill::Solid(Color::rgb(0.0, 0.0, 1.0)), ..Default::default() });
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_stroke_bounds_hold_its_mitered_corners() {
|
||||
// A flat triangle's sharp corners miter far past the path (Photoshop's miter limit is 100):
|
||||
// more than the stroke width, which is all the path's bounds grow by.
|
||||
let canvas = Rect::new(0, 0, 64, 64);
|
||||
let frame = stroked_frame(&[(20.0, 32.0), (44.0, 32.0), (32.0, 29.0)], 2.0);
|
||||
let drawn = crate::bounds::content_bounds(&shape_split::frame_stroke(&frame, canvas, SampleType::U8).unwrap());
|
||||
assert!(drawn.x0 < 20 - 2 && drawn.x1 > 44 + 2, "the miters reach past the width: {drawn:?}");
|
||||
for b in [layer_bounds(&frame, canvas), composite_bounds(&frame, canvas).unwrap(), effect_source_bounds(&frame, canvas)] {
|
||||
assert_eq!(b.union(&drawn), b, "{b:?} holds the stroke {drawn:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_frames_effects_follow_its_stroke() {
|
||||
// No children: the frame's drop shadow is shaped by its stroke alone.
|
||||
let mut d = doc_white(48, 48);
|
||||
let mut frame = stroked_frame(&[(14.0, 14.0), (34.0, 14.0), (34.0, 34.0), (14.0, 34.0)], 4.0);
|
||||
frame.effects.items = vec![Effect::DropShadow(shadow(3.0, 90.0))];
|
||||
d.layers.push(frame);
|
||||
let canvas = d.bounds();
|
||||
let drawn = crate::bounds::content_bounds(&shape_split::frame_stroke(&d.layers[1], canvas, SampleType::U8).unwrap());
|
||||
let b = effect_source_bounds(&d.layers[1], canvas);
|
||||
assert!(!drawn.is_empty() && b.union(&drawn) == b, "{b:?} holds the stroke {drawn:?}");
|
||||
assert!(close4(px(&d, 24, 36), [0.0, 0.0, 1.0, 1.0]), "stroke below the frame: {:?}", px(&d, 24, 36));
|
||||
assert!(close4(px(&d, 24, 39), [0.0, 0.0, 0.0, 1.0]), "shadow below the stroke: {:?}", px(&d, 24, 39));
|
||||
assert!(close4(px(&d, 24, 24), [1.0; 4]), "the frame's inside is empty: {:?}", px(&d, 24, 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_shadow_uses_global_light_and_knockout() {
|
||||
let mut d = fx_doc(vec![Effect::DropShadow(Shadow { use_global_light: true, ..shadow(5.0, 0.0) })]);
|
||||
@@ -3291,23 +3331,28 @@ fn effect_maps_with_nested_rayon_work_do_not_deadlock() {
|
||||
// #276: a tile building a layer's effect maps inside a once-init ran Rayon work; waiting on
|
||||
// it, its worker stole another tile of the same layer, which then blocked on the init held
|
||||
// further up its own stack. Eight threads (two rarely steal across that wait) and 64 tiles make that stealing likely; the cache is
|
||||
// purged every round so the maps are rebuilt. A watchdog turns a hang into a failure.
|
||||
// purged every round so the maps are rebuilt. A watchdog turns a hang into a failure: it
|
||||
// fails when 120 s pass without a render finishing, however long all 101 renders take.
|
||||
const ROUNDS: usize = 100;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let Ok(pool) = rayon::ThreadPoolBuilder::new().num_threads(8).build() else { return };
|
||||
let d = nested_parallel_fx_doc();
|
||||
let first = pool.install(|| render_tiled(&d, d.bounds(), 64));
|
||||
for _ in 0..100 {
|
||||
let _ = tx.send(());
|
||||
for _ in 0..ROUNDS {
|
||||
purge_effect_cache();
|
||||
let out = pool.install(|| render_tiled(&d, d.bounds(), 64));
|
||||
assert!(out.px == first.px, "renders differ between rounds");
|
||||
let _ = tx.send(());
|
||||
}
|
||||
let _ = tx.send(());
|
||||
});
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(120)) {
|
||||
Ok(()) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("effect-map rendering deadlocked (#276)"),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("render thread panicked"),
|
||||
for _ in 0..=ROUNDS {
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(120)) {
|
||||
Ok(()) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("effect-map rendering deadlocked (#276)"),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("render thread panicked"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,24 @@ pub fn is_identity_curve(points: &[CurvePoint]) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3D LUT file format (Photoshop's `LUTFormat`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LutFormat {
|
||||
#[serde(rename = "cube")]
|
||||
Cube,
|
||||
#[serde(rename = "3dl")]
|
||||
ThreeDl,
|
||||
#[serde(rename = "look")]
|
||||
Look,
|
||||
}
|
||||
|
||||
/// A Color Lookup's LUT file as loaded: its format and bytes.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LutSource {
|
||||
pub format: LutFormat,
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Entries in a pencil-drawn Curves map (one per 8-bit level, as in PSD `curv` maps).
|
||||
pub const CURVE_MAP_LEN: usize = 256;
|
||||
|
||||
@@ -260,6 +278,11 @@ pub enum Adjustment {
|
||||
/// Ordered dither of ±½ an 8-bit step to hide banding (Photoshop's "Dither").
|
||||
#[serde(default)]
|
||||
dither: bool,
|
||||
/// The LUT file `lut` was read from, embedded unchanged on PSD save as Photoshop does.
|
||||
/// None for a table made in PhotoCraft (a built-in look); a save also ignores it when
|
||||
/// it no longer parses to `lut`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
source: Option<LutSource>,
|
||||
},
|
||||
Invert,
|
||||
Posterize {
|
||||
|
||||
@@ -318,6 +318,10 @@ pub struct Group {
|
||||
pub expanded: bool,
|
||||
/// Set when this (top-level) group is an artboard: children are clipped to its rect.
|
||||
pub artboard: Option<Artboard>,
|
||||
/// A Photoshop Frame's stroke (Properties › Stroke; PSD `vstk` on the frame group): drawn
|
||||
/// along the group's enabled vector mask (the frame outline), above the framed content and
|
||||
/// not clipped by the frame. Ignored without a vector mask.
|
||||
pub frame_stroke: Option<ShapeStroke>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -703,7 +707,7 @@ impl Layer {
|
||||
Self::new(name, LayerContent::Raster(Surface::new(format)))
|
||||
}
|
||||
pub fn group(name: impl Into<String>, children: Vec<Layer>) -> Self {
|
||||
let mut l = Self::new(name, LayerContent::Group(Group { children, expanded: true, artboard: None }));
|
||||
let mut l = Self::new(name, LayerContent::Group(Group { children, expanded: true, artboard: None, frame_stroke: None }));
|
||||
l.blend = BlendMode::PassThrough;
|
||||
l
|
||||
}
|
||||
@@ -1509,7 +1513,7 @@ mod tests {
|
||||
assert_eq!(LayerContent::Adjustment(Adjustment::Invert).article(), "an");
|
||||
assert_eq!(LayerContent::Adjustment(Adjustment::Invert).kind_name(), "Adjustment");
|
||||
assert_eq!(LayerContent::Raster(Surface::new(PixelFormat::RGBA8)).article(), "a");
|
||||
assert_eq!(LayerContent::Group(Group { children: Vec::new(), expanded: true, artboard: None }).article(), "a");
|
||||
assert_eq!(LayerContent::Group(Group { children: Vec::new(), expanded: true, artboard: None, frame_stroke: None }).article(), "a");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,9 +39,9 @@ pub(crate) fn copy(s: &mut Session, p: &Value) -> Result<Option<Value>> {
|
||||
let Some(d) = s.active() else { return Ok(None) };
|
||||
let doc = &d.doc;
|
||||
let canvas = doc.bounds();
|
||||
// The surface the values come from, and which of its samples is the channel.
|
||||
let (src, k) = if crate::channel_cmds::is_channel_target(p) {
|
||||
(crate::channel_cmds::read_target_surface(doc, d.active_layer, p)?, 0)
|
||||
// A single channel copies as tone; CMYK and spot channels store ink.
|
||||
let (src, k, ink) = if crate::channel_cmds::is_channel_target(p) {
|
||||
(crate::channel_cmds::read_target_surface(doc, d.active_layer, p)?, 0, crate::channel_cmds::is_spot_target(doc, p))
|
||||
} else if p.get("target").is_some() {
|
||||
return Ok(None);
|
||||
} else {
|
||||
@@ -50,11 +50,11 @@ pub(crate) fn copy(s: &mut Session, p: &Value) -> Result<Option<Value>> {
|
||||
ChannelTarget::Color(k) => {
|
||||
let id = d.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let surf = doc.layer(id).and_then(Layer::surface).ok_or_else(|| EngineError::Other("the active layer has no pixels".into()))?;
|
||||
(surf, k)
|
||||
(surf, k, surf.format().mode == ColorMode::Cmyk)
|
||||
}
|
||||
ChannelTarget::Alpha(i) => {
|
||||
let c = doc.channels.get(i).ok_or_else(|| EngineError::Other(format!("no alpha channel {i} (document has {})", doc.channels.len())))?;
|
||||
(&c.surface, 0)
|
||||
(&c.surface, 0, c.spot.is_some())
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -73,8 +73,9 @@ pub(crate) fn copy(s: &mut Session, p: &Value) -> Result<Option<Value>> {
|
||||
let len = (values.len() / n).checked_mul(2).ok_or_else(|| EngineError::Other("the channel is too large to copy".into()))?;
|
||||
let mut px = crate::allocation::capacity(len, "copying a channel")?;
|
||||
for (i, p) in values.chunks_exact(n).enumerate() {
|
||||
let value = p.get(k).copied().unwrap_or(0.0);
|
||||
px.push(if src.format().mode == ColorMode::Cmyk { 1.0 - value } else { value });
|
||||
// A CMYK or spot channel copies as its tone (#5787).
|
||||
px.push(p.get(k).map_or(0.0, |&v| if ink { 1.0 - v } else { v }));
|
||||
|
||||
px.push(cover.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0)));
|
||||
}
|
||||
let mut surface = Surface::new(fmt);
|
||||
@@ -101,8 +102,8 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit:
|
||||
let area = src.content_bounds().intersect(&canvas);
|
||||
s.edit(label, |doc, _| {
|
||||
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
|
||||
let n = surf.format().channels();
|
||||
let ink = surf.format().mode == ColorMode::Cmyk;
|
||||
let (n, mode) = (surf.format().channels(), surf.format().mode);
|
||||
|
||||
if k >= n {
|
||||
return Err(EngineError::Other(format!("no colour channel {k} in this layer")));
|
||||
}
|
||||
@@ -116,8 +117,8 @@ pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit:
|
||||
let mut px = crate::allocation::read_region(surf, area, "pasting into a channel")?;
|
||||
for (i, (dst, s)) in px.chunks_exact_mut(n).zip(&rgba).enumerate() {
|
||||
let w = s[3] * weight.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0));
|
||||
let tone = photocraft_color::convert::rgb_to_gray([s[0], s[1], s[2]]);
|
||||
let v = if ink { 1.0 - tone } else { tone };
|
||||
let v = crate::channel_cmds::tone(mode, photocraft_color::convert::rgb_to_gray([s[0], s[1], s[2]]));
|
||||
|
||||
if let Some(c) = dst.get_mut(k) {
|
||||
*c += (v - *c) * w.clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,65 @@ fn alpha_channels_copy_and_paste_into_colour_channels() {
|
||||
assert!(near(&px(&s, 30, 15), &[0.0, 0.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_channels_copy_and_paste_as_tone() {
|
||||
// #5787: copying a CMYK channel gives its tone (white where there is no ink), and pasting a
|
||||
// tone into a CMYK channel writes it back as ink.
|
||||
for depth in [8, 16] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": W, "height": H, "mode": "cmyk", "depth": depth, "background": "white"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": W / 2, "height": H})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#00ffff"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let (left, right) = (px(&s, 5, 5), px(&s, 30, 5));
|
||||
s.execute("channel.target", json!({"channel": "cyan"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let clip = &s.clipboard.as_ref().unwrap().surface;
|
||||
assert!((clip.pixel(30, 5)[0] - 1.0).abs() < 1e-3, "depth {depth}: no ink copies as white");
|
||||
assert!((clip.pixel(5, 5)[0] - (1.0 - left[0])).abs() < 1e-3, "depth {depth}: ink copies dark");
|
||||
// Cyan pasted into Black: black gets cyan's ink, and white stays white.
|
||||
s.execute("channel.target", json!({"channel": "black"})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert!(near(&px(&s, 30, 5), &right), "depth {depth}: no ink stays no ink, {:?}", px(&s, 30, 5));
|
||||
let p = px(&s, 5, 5);
|
||||
assert!((p[3] - left[0]).abs() < 2e-3 && near(&p[..3], &left[..3]), "depth {depth}: black = old cyan, {p:?} vs {left:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_channels_copy_and_paste_as_tone() {
|
||||
// #5787: a spot channel stores ink but copies as its tone, and a tone pasted into it goes back
|
||||
// to ink: spot -> Black prints the spot's ink in black, Cyan -> spot gives the spot Cyan's ink.
|
||||
for depth in [8, 16] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": W, "height": H, "mode": "cmyk", "depth": depth, "background": "white"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": W / 2, "height": H})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#00ffff"})).unwrap();
|
||||
let cyan = px(&s, 5, 5)[0];
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": H})).unwrap();
|
||||
let spot = s.execute("channel.newSpot", json!({"color": "#ff00ff", "solidity": 100})).unwrap()["channel"].as_u64().unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("channel.target", json!({"channel": spot})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let clip = &s.clipboard.as_ref().unwrap().surface;
|
||||
assert!(clip.pixel(5, 5)[0] < 1e-3 && (clip.pixel(30, 5)[0] - 1.0).abs() < 1e-3, "depth {depth}: ink copies dark");
|
||||
let expected = clip.clone();
|
||||
// An explicit spot target must keep the same tone while multiple colour rows are selected.
|
||||
s.execute("channel.target", json!({"colors": [0, 2]})).unwrap();
|
||||
s.execute("edit.copy", json!({"target": {"channel": spot}})).unwrap();
|
||||
assert_eq!(s.clipboard.as_ref().unwrap().surface, expected, "depth {depth}: explicit spot target overrides the colour set");
|
||||
s.execute("channel.target", json!({"channel": "black"})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert!((px(&s, 5, 5)[3] - 1.0).abs() < 2e-3 && px(&s, 30, 5)[3] < 2e-3, "depth {depth}: black = the spot's ink");
|
||||
s.execute("channel.target", json!({"channel": "cyan"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("channel.target", json!({"channel": spot})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
let ink = |x| s.active().unwrap().doc.channels[spot as usize].surface.sample_channel(x, 5, 0);
|
||||
assert!(near(&[ink(5), ink(15), ink(30)], &[cyan, cyan, 0.0]), "depth {depth}: spot = Cyan's ink, {:?}", [ink(5), ink(15), ink(30)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_an_alpha_channel_without_a_pixel_layer() {
|
||||
let mut s = session(8);
|
||||
|
||||
@@ -90,6 +90,7 @@ pub mod gradient_fill_cmds;
|
||||
pub mod group_view_cmds;
|
||||
pub mod history_cmds;
|
||||
pub mod history_menu_cmds;
|
||||
pub mod history_options_cmds;
|
||||
pub mod history_select_cmds;
|
||||
pub mod history_source_cmds;
|
||||
pub mod image_cmds;
|
||||
@@ -616,7 +617,11 @@ fn build() -> Vec<CommandSpec> {
|
||||
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||||
// Clipping and channel changes reach the layers around it: recomposite everything.
|
||||
let local = p.get("clipped").is_none() && p.get("channels").is_none();
|
||||
s.edit(label, |doc, _| {
|
||||
// The eye: a history state only with History Options › Make Layer Visibility
|
||||
// Changes Undoable.
|
||||
let only_visibility = changed.as_slice() == ["visible"];
|
||||
let record = !only_visibility || s.visibility_undoable();
|
||||
s.edit_with(label, record, |doc, _| {
|
||||
// The Background has no opacity, fill or blend mode: like Photoshop, setting
|
||||
// one that changes its look turns it into a normal layer ("Layer 0") first.
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64).is_some_and(|v| v < 1.0);
|
||||
@@ -1312,6 +1317,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
v.extend(crate::analysis_cmds::specs());
|
||||
v.extend(crate::notes_cmds::specs());
|
||||
v.extend(crate::history_cmds::specs());
|
||||
v.extend(crate::history_options_cmds::specs());
|
||||
v.extend(crate::history_menu_cmds::specs());
|
||||
v.extend(crate::history_source_cmds::specs());
|
||||
v.extend(crate::history_select_cmds::specs());
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! result through the selection like the other Image › Adjustments commands.
|
||||
|
||||
use photocraft_algo::tone::{self, HdrToning, MatchColor, ShadowsHighlights};
|
||||
use photocraft_doc::adjust::{LutFormat, LutSource};
|
||||
use photocraft_doc::{Adjustment, Document, Layer, LayerContent, Rect};
|
||||
use photocraft_raster::{Surface, to_rgba};
|
||||
use serde_json::{Value, json};
|
||||
@@ -104,34 +105,38 @@ pub fn selective_from_params(p: &Value, base: Option<&Adjustment>) -> Result<Adj
|
||||
/// error.
|
||||
pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result<Adjustment> {
|
||||
const CMD: &str = "colorLookup";
|
||||
let (mut name, mut lut, mut size, mut tetrahedral, mut dither) = match base {
|
||||
Some(Adjustment::ColorLookup { name, lut, size, tetrahedral, dither }) => (name.clone(), lut.clone(), *size, *tetrahedral, *dither),
|
||||
_ => (String::new(), None, 0, false, false),
|
||||
let (mut name, mut lut, mut size, mut tetrahedral, mut dither, mut source) = match base {
|
||||
Some(Adjustment::ColorLookup { name, lut, size, tetrahedral, dither, source }) => {
|
||||
(name.clone(), lut.clone(), *size, *tetrahedral, *dither, source.clone())
|
||||
}
|
||||
_ => (String::new(), None, 0, false, false, None),
|
||||
};
|
||||
let mut loaded: Option<(photocraft_cms::lutfile::LutFile, String)> = None;
|
||||
// The parsed table, its label and (for a file) the file as loaded, which a PSD save embeds.
|
||||
let mut loaded: Option<(photocraft_cms::lutfile::LutFile, String, Option<LutSource>)> = None;
|
||||
if let Some(id) = opt_str(CMD, p, "lut")? {
|
||||
if id == "none" || id.is_empty() {
|
||||
name.clear();
|
||||
lut = None;
|
||||
size = 0;
|
||||
source = None;
|
||||
} else {
|
||||
let f = photocraft_cms::lutfile::builtin(id).ok_or_else(|| bad(CMD, format!("unknown look `{id}` (built-ins: {})", builtin_ids())))?;
|
||||
let label = f.title.clone();
|
||||
loaded = Some((f, label));
|
||||
loaded = Some((f, label, None));
|
||||
}
|
||||
}
|
||||
let file_name = opt_str(CMD, p, "fileName")?;
|
||||
if let Some(text) = opt_str(CMD, p, "data")? {
|
||||
let file_name = file_name.unwrap_or("lut.cube");
|
||||
let f = photocraft_cms::lutfile::parse(file_name, text.as_bytes()).map_err(|e| bad(CMD, e.0))?;
|
||||
loaded = Some((f, base_name(file_name)));
|
||||
loaded = Some((f, base_name(file_name), Some(lut_source(file_name, text.as_bytes().to_vec()))));
|
||||
}
|
||||
if let Some(path) = opt_str(CMD, p, "file")?.filter(|s| !s.is_empty()) {
|
||||
let bytes = read_file(path).ok_or_else(|| bad(CMD, format!("can't read {path}")))?;
|
||||
let f = photocraft_cms::lutfile::parse(path, &bytes).map_err(|e| bad(CMD, format!("{path}: {}", e.0)))?;
|
||||
loaded = Some((f, base_name(path)));
|
||||
loaded = Some((f, base_name(path), Some(lut_source(path, bytes))));
|
||||
}
|
||||
if let Some((f, label)) = loaded {
|
||||
if let Some((f, label, src)) = loaded {
|
||||
if !f.domain_is_default() {
|
||||
return Err(bad(
|
||||
CMD,
|
||||
@@ -144,6 +149,7 @@ pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result<Adjust
|
||||
name = label;
|
||||
size = f.size as u32;
|
||||
lut = Some(std::sync::Arc::new(f.data));
|
||||
source = src;
|
||||
}
|
||||
match opt_str(CMD, p, "interpolation")? {
|
||||
None => {}
|
||||
@@ -157,7 +163,17 @@ pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result<Adjust
|
||||
if let Some(b) = opt_bool(CMD, p, "dither")? {
|
||||
dither = b;
|
||||
}
|
||||
Ok(Adjustment::ColorLookup { name, lut, size, tetrahedral, dither })
|
||||
Ok(Adjustment::ColorLookup { name, lut, size, tetrahedral, dither, source })
|
||||
}
|
||||
|
||||
/// A loaded LUT file, tagged with the format [`photocraft_cms::lutfile::parse`] read it as.
|
||||
fn lut_source(file_name: &str, bytes: Vec<u8>) -> LutSource {
|
||||
let format = match photocraft_cms::lutfile::Format::detect(file_name, &bytes) {
|
||||
photocraft_cms::lutfile::Format::Cube => LutFormat::Cube,
|
||||
photocraft_cms::lutfile::Format::ThreeDl => LutFormat::ThreeDl,
|
||||
photocraft_cms::lutfile::Format::Look => LutFormat::Look,
|
||||
};
|
||||
LutSource { format, bytes: std::sync::Arc::new(bytes) }
|
||||
}
|
||||
|
||||
fn builtin_ids() -> String {
|
||||
|
||||
@@ -170,7 +170,7 @@ fn color_lookup_builtin_data_and_errors() {
|
||||
// Switching interpolation keeps the table.
|
||||
s.execute("layer.setAdjustment", json!({"layer": id.0, "interpolation": "tetrahedral", "dither": true})).unwrap();
|
||||
match &doc(&s).layer(id).unwrap().content {
|
||||
LayerContent::Adjustment(Adjustment::ColorLookup { lut: Some(_), size: 33, tetrahedral: true, dither: true, name }) => {
|
||||
LayerContent::Adjustment(Adjustment::ColorLookup { lut: Some(_), size: 33, tetrahedral: true, dither: true, name, .. }) => {
|
||||
assert_eq!(name, "Day for Night")
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
@@ -220,6 +220,53 @@ LUT_3D_SIZE 2
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
use photocraft_doc::adjust::{LutFormat, LutSource};
|
||||
// #6544: a .3dl / .look file is kept as loaded and embedded unchanged in a PSD.
|
||||
let mut threedl = String::from("0 1023\n");
|
||||
for r in 0..2 {
|
||||
for g in 0..2 {
|
||||
for b in 0..2 {
|
||||
threedl.push_str(&format!("{} {} {}\n", (1 - r) * 4095, (1 - g) * 4095, (1 - b) * 4095));
|
||||
}
|
||||
}
|
||||
}
|
||||
let hex: String = photocraft_cms::lutfile::LutFile::identity(2).data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect();
|
||||
let look = format!("<look><LUT><size>2</size><data>{hex}</data></LUT></look>");
|
||||
for (text, file_name, format, psd_format) in
|
||||
[(threedl.as_str(), "Invert.3dl", LutFormat::ThreeDl, "LUTFormat3DL"), (look.as_str(), "Grade.look", LutFormat::Look, "LUTFormatLOOK")]
|
||||
{
|
||||
let mut s = session(8, "rgb");
|
||||
let r = s.execute("layer.newAdjustmentLayer.colorLookup", json!({"data": text, "fileName": file_name})).unwrap();
|
||||
let id = photocraft_doc::LayerId(r["layer"].as_u64().unwrap());
|
||||
let want = LutSource { format, bytes: std::sync::Arc::new(text.as_bytes().to_vec()) };
|
||||
let source_of = |s: &Session| match adjustment_of(s, id) {
|
||||
Adjustment::ColorLookup { source, .. } => source,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(source_of(&s), Some(want.clone()), "{file_name}");
|
||||
// Settings that don't touch the table keep the file.
|
||||
s.execute("layer.setAdjustment", json!({"layer": id.0, "interpolation": "tetrahedral", "dither": true})).unwrap();
|
||||
assert_eq!(source_of(&s), Some(want.clone()), "{file_name}");
|
||||
// Saved as PSD and reopened: same format tag, same bytes, same name.
|
||||
let psd = photocraft_io::export(doc(&s), "psd", &photocraft_io::ExportOptions::default()).unwrap().bytes;
|
||||
let back = photocraft_io::import("x.psd", &psd).unwrap().document;
|
||||
let layer = back.walk().into_iter().map(|(_, _, l)| l).find(|l| matches!(l.content, LayerContent::Adjustment(Adjustment::ColorLookup { .. }))).unwrap();
|
||||
let LayerContent::Adjustment(Adjustment::ColorLookup { name, source, .. }) = &layer.content else { panic!("not a Color Lookup") };
|
||||
assert_eq!((name.as_str(), source.as_ref()), (file_name, Some(&want)), "{psd_format}");
|
||||
// Choosing a built-in look replaces the table, so the file goes too.
|
||||
s.execute("layer.setAdjustment", json!({"layer": id.0, "lut": "dayForNight"})).unwrap();
|
||||
assert_eq!(source_of(&s), None);
|
||||
}
|
||||
// "none" drops it as well.
|
||||
let mut s = session(8, "rgb");
|
||||
let r = s.execute("layer.newAdjustmentLayer.colorLookup", json!({"data": threedl, "fileName": "Invert.3dl"})).unwrap();
|
||||
let id = photocraft_doc::LayerId(r["layer"].as_u64().unwrap());
|
||||
s.execute("layer.setAdjustment", json!({"layer": id.0, "lut": "none"})).unwrap();
|
||||
assert!(matches!(adjustment_of(&s, id), Adjustment::ColorLookup { source: None, lut: None, .. }));
|
||||
}
|
||||
|
||||
fn adjustment_of(s: &Session, id: photocraft_doc::LayerId) -> Adjustment {
|
||||
match &doc(s).layer(id).unwrap().content {
|
||||
LayerContent::Adjustment(a) => a.clone(),
|
||||
|
||||
@@ -197,7 +197,8 @@ pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess
|
||||
Ok(v) => {
|
||||
// As in `Session::edit`: undo targets what was selected just before the step (#1356).
|
||||
st.history.set_current_layers(prior);
|
||||
st.history.record(label, before, st.layer_target());
|
||||
st.history.record(label, before.clone(), st.layer_target());
|
||||
st.history.add_current_visibility(&crate::visibility_changes(&before, &st.doc));
|
||||
st.trim_history();
|
||||
st.coalesce = None;
|
||||
Ok(v)
|
||||
|
||||
@@ -38,7 +38,7 @@ mod steps_make;
|
||||
mod tables;
|
||||
|
||||
#[cfg(test)]
|
||||
use adjustments::selective_from;
|
||||
use adjustments::{levels_from, selective_from};
|
||||
pub use convert::{Imported, Skipped, Unsupported, export, function_key_name, import, parse_function_key};
|
||||
#[cfg(test)]
|
||||
use convert::{MAX_NAME_CHARS, export_item, normalise};
|
||||
@@ -56,7 +56,7 @@ use files::{load, save};
|
||||
#[cfg(test)]
|
||||
use steps_doc::crop_from;
|
||||
#[cfg(test)]
|
||||
use steps_image::transform_to;
|
||||
use steps_image::{apply_image_from, transform_to};
|
||||
#[cfg(test)]
|
||||
use steps_layer::quick_mask_ref;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::descriptors::{
|
||||
};
|
||||
use super::events::pixel_target;
|
||||
use super::fields::{F, Kind, Mapped, fields_from, fields_to, step};
|
||||
use super::tables::{CHANNELS, INTERPOLATION, RADIAL_METHOD, RADIAL_QUALITY, lookup, lookup_back};
|
||||
use super::tables::{CHANNELS, INTERPOLATION, RADIAL_METHOD, RADIAL_QUALITY, channel_of, lookup, lookup_back};
|
||||
use super::*;
|
||||
|
||||
/// `transform`: Photoshop records a Free Transform relative to the frame (offset of its centre,
|
||||
@@ -143,7 +143,7 @@ pub(super) fn apply_image_from(d: &Descriptor) -> Mapped {
|
||||
match item {
|
||||
ReferenceItem::Enumerated { class, value, .. } if class.class_id.is("Chnl") => {
|
||||
let v = trimmed(value);
|
||||
let Some(channel) = lookup(SOURCE_CHANNELS, &v).or_else(|| lookup(CHANNELS, &v)) else { return unsupported };
|
||||
let Some(channel) = lookup(SOURCE_CHANNELS, &v).or_else(|| channel_of(&v)) else { return unsupported };
|
||||
source.insert("channel".into(), channel.into());
|
||||
}
|
||||
// A named (alpha) channel.
|
||||
|
||||
@@ -78,17 +78,24 @@ pub(super) const CHANNELS: &[(&str, &str)] = &[
|
||||
("Mgnt", "magenta"),
|
||||
("Yllw", "yellow"),
|
||||
("Blck", "black"),
|
||||
("Lmnc", "lightness"),
|
||||
// Lab Lightness: Photoshop rejects `Lmnc` ("luminance") on a Lab document (#9384).
|
||||
("Lght", "lightness"),
|
||||
("A", "a"),
|
||||
("B", "b"),
|
||||
];
|
||||
|
||||
/// The channel key of a `Chnl` enumeration. `Lmnc` is what PhotoCraft wrote for Lightness before
|
||||
/// #9384; reading it keeps those actions loading.
|
||||
pub(super) fn channel_of(code: &str) -> Option<&'static str> {
|
||||
lookup(CHANNELS, code).or_else(|| (code.trim_end() == "Lmnc").then_some("lightness"))
|
||||
}
|
||||
|
||||
/// The channel an adjustment entry's `Chnl` reference names: `None` = the composite.
|
||||
pub(super) fn channel_key(d: &Descriptor) -> Option<Option<&'static str>> {
|
||||
let r = get_ref(d, "Chnl")?;
|
||||
Some(match target(r) {
|
||||
Target::Ordinal(o) if o == "Trgt" || o == "Cmps" => None,
|
||||
Target::Ordinal(o) => Some(lookup(CHANNELS, &o)?),
|
||||
Target::Ordinal(o) => Some(channel_of(&o)?),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2644,6 +2644,44 @@ fn levels_and_curves_name_the_composite_channel_as_photoshop_records_it() {
|
||||
assert_eq!(channels(&curves), vec![composite, red]);
|
||||
}
|
||||
|
||||
/// A Lab document's Lightness channel is `Chnl.Lght` (#9384): Photoshop plays a Levels or Apply
|
||||
/// Image step naming it and rejects `Chnl.Lmnc` ("luminance"), which PhotoCraft wrote before.
|
||||
/// Steps PhotoCraft saved with `Lmnc` still load.
|
||||
#[test]
|
||||
fn lab_lightness_channel_is_written_and_read_as_photoshop_names_it() {
|
||||
let exported = |id: &str, p: Value| export_item(id, &p, "S").and_then(|i| i.descriptor).unwrap_or_else(|| panic!("{id} does not export"));
|
||||
let entry_codes = |d: &Descriptor| -> Vec<String> {
|
||||
get_list(d, "Adjs")
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
D::Descriptor(e) => match get_ref(e, "Chnl")?.first()? {
|
||||
ReferenceItem::Enumerated { value, .. } => Some(trimmed(value)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let levels = exported("image.adjustments.levels", json!({"lightness": {"gamma": 1.5}, "a": {"inBlack": 3}}));
|
||||
assert_eq!(entry_codes(&levels), ["Cmps", "Lght", "A"]);
|
||||
let curves = exported("image.adjustments.curves", json!({"lightness": [[0, 10], [255, 245]]}));
|
||||
assert_eq!(entry_codes(&curves), ["Lght"]);
|
||||
let apply = exported("image.applyImage", json!({"sourceLayer": "merged", "sourceChannel": "Lightness"}));
|
||||
let source = get_ref(get_obj(&apply, "With").unwrap(), "T ").unwrap();
|
||||
assert!(matches!(&source[0], ReferenceItem::Enumerated { value, .. } if trimmed(value) == "Lght"), "{source:?}");
|
||||
|
||||
// What Photoshop records, and what PhotoCraft wrote before, both read as Lightness.
|
||||
let chnl = |c: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code(c) }]);
|
||||
for c in ["Lght", "Lmnc"] {
|
||||
let lvls = desc("Lvls").with("Adjs", D::List(vec![D::Descriptor(desc("LvlA").with("Chnl", chnl(c)).with("Gmm ", D::Double(1.5)))]));
|
||||
assert_eq!(levels_from(&lvls), Some(json!({"lightness": {"gamma": 1.5}})), "{c}");
|
||||
let with = desc("Clcl").with("T ", chnl(c));
|
||||
let Mapped::Steps(steps) = apply_image_from(&desc("null").with("With", D::Descriptor(with))) else { panic!("{c}: Apply Image does not import") };
|
||||
assert_eq!(steps[0].1["source"]["channel"], "lightness", "{c}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop sets one colour per step: a step that sets both doesn't export rather than drop one.
|
||||
#[test]
|
||||
fn set_colors_exports_only_a_single_colour() {
|
||||
|
||||
@@ -114,6 +114,7 @@ mod view;
|
||||
use calc::{apply_image, calculations};
|
||||
use manage::{delete_channel, duplicate_channel, move_channel, new_channel, new_spot, options, rename_channel, set_visible};
|
||||
use planes::edit_mode;
|
||||
pub(crate) use planes::tone;
|
||||
pub use planes::{
|
||||
GraySurface, IsolatedCurveChannel, channel_format, color_count, curve_channel_for_colors, filter_mask_curve_channel, isolated_curve_channel,
|
||||
isolated_curve_channel_for, layer_mask_curve_channel, next_alpha_name,
|
||||
@@ -124,8 +125,8 @@ use selection::{load_selection, quick_mask, save_selection};
|
||||
use spot::{merge, merge_spot, mergeable, split};
|
||||
use targeting::{SLOT_KEY, target_cmd, target_slot};
|
||||
pub(crate) use targeting::{
|
||||
Target, channel_surface_for_filter, clear_channel, clear_color_channel, color_restriction, edits_channel, filter_mask_layer, inject_target,
|
||||
is_channel_target, mask_target_enabled, restrict_to_color, routed, target_of, target_surface, targeted_channel,
|
||||
Target, channel_surface_for_filter, clear_channel, clear_color_channel, color_restriction, edits_channel, filter_mask_layer, inject_target, invert_region,
|
||||
is_channel_target, is_spot_target, mask_target_enabled, restrict_to_color, routed, target_of, target_surface, targeted_channel,
|
||||
};
|
||||
pub use targeting::{follows_filter_mask, follows_target, read_target_surface};
|
||||
pub(crate) use view::fix_view;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::manage::gray_document;
|
||||
use super::planes::{channel_format, next_alpha_name, plane_surface, read_plane};
|
||||
use super::refs::{Blending, bad, mask_plane, source, source_spec};
|
||||
use super::targeting::{Target, is_channel_target, target_of, target_surface};
|
||||
use super::planes::{channel_format, edit_mode, next_alpha_name, plane_surface, read_plane, tone};
|
||||
use super::refs::{Blending, bad, doc_index, mask_plane, source, source_spec};
|
||||
use super::targeting::{Target, is_channel_target, is_spot_target, target_of, target_surface};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -13,7 +13,11 @@ pub(super) fn apply_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let opacity = p.get("opacity").and_then(Value::as_f64).unwrap_or(100.0).clamp(0.0, 100.0) as f32 / 100.0;
|
||||
let preserve = p.get("preserveTransparency").and_then(Value::as_bool).unwrap_or(false);
|
||||
let src_spec = source_spec(p, "source", "source").unwrap_or_else(|| json!({}));
|
||||
let src = source(s, &src_spec, cmd, false)?;
|
||||
// A composite's planes are in its own document's mode, which may not be the target's.
|
||||
let src_mode = s.documents().get(doc_index(s, &src_spec, "document")?).map(|d| edit_mode(&d.doc)).ok_or(EngineError::NoDocument)?;
|
||||
// A CMYK composite reaches a channel or a layer mask as its luminosity, as Calculations reads
|
||||
// it: its planes are ink, and an average of their tones would leave Black alone light gray.
|
||||
let src = source(s, &src_spec, cmd, src_mode == ColorMode::Cmyk && (is_channel_target(p) || target_of(p) == Target::Mask))?;
|
||||
let mask = mask_plane(s, p, cmd)?;
|
||||
let layer = s.active().and_then(|d| d.active_layer);
|
||||
s.edit("Apply Image", |doc, _| {
|
||||
@@ -22,12 +26,22 @@ pub(super) fn apply_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let weight = |i: usize| opacity * mask.as_ref().map_or(1.0, |m| m[i]) * selection.as_ref().map_or(1.0, |m| m[i]);
|
||||
let mask_target = target_of(p) == Target::Mask;
|
||||
if is_channel_target(p) || mask_target {
|
||||
// A grayscale target gets the luminosity of a colour source (one plane).
|
||||
// A grayscale target gets one plane: a CMYK composite's luminosity (above), the average of
|
||||
// another composite's planes.
|
||||
let srcp =
|
||||
if src.len() == 1 { src[0].clone() } else { (0..src[0].len()).map(|i| src.iter().map(|pl| pl[i]).sum::<f32>() / src.len() as f32).collect() };
|
||||
let spot = is_spot_target(doc, p);
|
||||
let ink = |v: f32| if spot { 1.0 - v } else { v };
|
||||
let (surf, _) = target_surface(doc, layer, p)?;
|
||||
let base = read_plane(surf, area);
|
||||
let out: Vec<f32> = base.iter().enumerate().map(|(i, &b)| b + (blending.apply(b, srcp[i]) - b) * weight(i)).collect();
|
||||
let out: Vec<f32> = base
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &b)| {
|
||||
let b = ink(b);
|
||||
ink(b + (blending.apply(b, srcp[i]) - b) * weight(i))
|
||||
})
|
||||
.collect();
|
||||
if mask_target {
|
||||
// A layer mask keeps its default (reveal or hide) outside the canvas.
|
||||
if surf.channels() != 1 {
|
||||
@@ -64,9 +78,15 @@ pub(super) fn apply_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// source-over, so applying to an empty layer copies the source in any mode.
|
||||
let out_a = if preserve { base_a } else { w + base_a * (1.0 - w) };
|
||||
for (c, b) in px[..colors].iter_mut().enumerate() {
|
||||
let top = src.get(c).unwrap_or(&src[0])[i];
|
||||
let blended = top + (blending.apply(*b, top) - top) * base_a;
|
||||
*b = if preserve { *b + (blended - *b) * w } else { (w * blended + base_a * (1.0 - w) * *b) / out_a };
|
||||
// Blended as tone, as Photoshop does: a CMYK layer's ink turns into tone and back
|
||||
// (Multiply darkens, so it adds ink). One plane (a channel, a mask) is a tone already.
|
||||
// A CMYK composite onto another mode darkens each tone by its Black, so Black
|
||||
// alone stays black.
|
||||
let k = if src_mode == ColorMode::Cmyk && sf.mode != ColorMode::Cmyk { src.get(3).map_or(1.0, |pl| tone(src_mode, pl[i])) } else { 1.0 };
|
||||
let top = if src.len() > 1 { tone(src_mode, src.get(c).unwrap_or(&src[0])[i]) * k } else { src[0][i] };
|
||||
let base = tone(sf.mode, *b);
|
||||
let blended = top + (blending.apply(base, top) - top) * base_a;
|
||||
*b = tone(sf.mode, if preserve { base + (blended - base) * w } else { (w * blended + base_a * (1.0 - w) * base) / out_a });
|
||||
}
|
||||
if sf.alpha {
|
||||
px[colors] = out_a;
|
||||
|
||||
@@ -166,6 +166,13 @@ pub(super) fn materialize(s: &mut Surface, r: Rect) {
|
||||
s.write_region(r, &v);
|
||||
}
|
||||
|
||||
/// A colour channel sample as the Channels panel shows it: CMYK stores ink (1 = full ink) but a
|
||||
/// CMYK channel reads as its tone (white = no ink), as in Photoshop. Its own inverse, so it also
|
||||
/// turns a tone back into ink. Other modes are unchanged.
|
||||
pub(crate) fn tone(mode: ColorMode, v: f32) -> f32 {
|
||||
if mode == ColorMode::Cmyk { 1.0 - v } else { v }
|
||||
}
|
||||
|
||||
pub(super) fn luminosity(rgba: [f32; 4]) -> f32 {
|
||||
0.299 * rgba[0] + 0.587 * rgba[1] + 0.114 * rgba[2]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::planes::{color_count, edit_mode, luminosity, read_plane};
|
||||
use super::planes::{color_count, edit_mode, luminosity, read_plane, tone};
|
||||
use super::view::{color_names, composite_name};
|
||||
use super::*;
|
||||
|
||||
@@ -127,11 +127,19 @@ fn ref_planes(doc: &Document, layer: Option<LayerId>, active: Option<LayerId>, r
|
||||
let (px, n) = native_pixels(doc, layer)?;
|
||||
let planes: Vec<Vec<f32>> = (0..colors).map(|c| px.chunks_exact(n).map(|p| p[c]).collect()).collect();
|
||||
match r {
|
||||
ChanRef::Color(k) => vec![planes.get(k).cloned().ok_or_else(|| EngineError::Other(format!("no colour channel {k}")))?],
|
||||
ChanRef::Color(k) => {
|
||||
let plane = planes.get(k).ok_or_else(|| EngineError::Other(format!("no colour channel {k}")))?;
|
||||
vec![plane.iter().map(|&v| tone(edit_mode(doc), v)).collect()]
|
||||
}
|
||||
_ => planes,
|
||||
}
|
||||
}
|
||||
ChanRef::Alpha(i) => vec![read_plane(&doc.channels.get(i).ok_or_else(|| EngineError::Other(format!("no alpha channel {i}")))?.surface, area)],
|
||||
ChanRef::Alpha(i) => {
|
||||
let ch = doc.channels.get(i).ok_or_else(|| EngineError::Other(format!("no alpha channel {i}")))?;
|
||||
let plane = read_plane(&ch.surface, area);
|
||||
// A spot channel stores ink (1 = solid) but reads as its tone, as in Photoshop.
|
||||
vec![if ch.spot.is_some() { plane.into_iter().map(|v| 1.0 - v).collect() } else { plane }]
|
||||
}
|
||||
ChanRef::QuickMask => vec![read_plane(&doc.quick_mask.as_ref().ok_or_else(|| EngineError::Other("not in Quick Mask mode".into()))?.surface, area)],
|
||||
ChanRef::Selection => vec![sel::mask_from_surface(doc.selection.as_ref(), area)],
|
||||
ChanRef::Transparency => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::manage::{fix_view_now, gray_document};
|
||||
use super::planes::{channel_format, edit_mode, plane_surface, read_plane};
|
||||
use super::planes::{channel_format, edit_mode, plane_surface, read_plane, tone};
|
||||
use super::refs::{ChanRef, alpha_index, bad, gray_plane, native_pixels};
|
||||
use super::view::{ChannelTarget, ChannelView, color_names};
|
||||
use super::*;
|
||||
@@ -66,7 +66,7 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let short = |name: &str| name.chars().next().map(|c| c.to_ascii_uppercase().to_string()).unwrap_or_default();
|
||||
let mut docs: Vec<Document> = Vec::new();
|
||||
for (c, name) in names.iter().enumerate() {
|
||||
let plane: Vec<f32> = px.chunks_exact(n).map(|p| p[c]).collect();
|
||||
let plane: Vec<f32> = px.chunks_exact(n).map(|p| tone(mode, p[c])).collect();
|
||||
docs.push(gray_document(&format!("{}_{}", doc.name, short(name)), &doc, &plane));
|
||||
}
|
||||
for ch in &doc.channels {
|
||||
@@ -133,7 +133,7 @@ pub(super) fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut data = Vec::with_capacity(npx * fmt.channels());
|
||||
for i in 0..npx {
|
||||
for pl in &planes[..cc] {
|
||||
data.push(pl[i].clamp(0.0, 1.0));
|
||||
data.push(tone(mode, pl[i]).clamp(0.0, 1.0));
|
||||
}
|
||||
data.push(1.0);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,19 @@ pub(crate) fn is_channel_target(p: &Value) -> bool {
|
||||
matches!(target_of(p), Target::Alpha(_) | Target::QuickMask)
|
||||
}
|
||||
|
||||
/// The target is a spot channel: it stores ink but takes tone, as it reads.
|
||||
pub(crate) fn is_spot_target(doc: &Document, p: &Value) -> bool {
|
||||
matches!(target_of(p), Target::Alpha(i) if doc.channels.get(i).is_some_and(|c| c.spot.is_some()))
|
||||
}
|
||||
|
||||
/// Inverts `r` of a channel surface (ink to tone and back).
|
||||
pub(crate) fn invert_region(s: &mut Surface, r: Rect) -> Result<()> {
|
||||
let mut v = crate::allocation::read_region(s, r, "inverting a channel")?;
|
||||
v.iter_mut().for_each(|x| *x = 1.0 - *x);
|
||||
s.try_write_region(r, &v)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The surface a pixel command writes to and whether the layer's transparency is locked.
|
||||
pub(crate) fn target_surface<'a>(doc: &'a mut Document, layer: Option<LayerId>, p: &Value) -> Result<(&'a mut Surface, bool)> {
|
||||
match target_of(p) {
|
||||
|
||||
@@ -120,6 +120,38 @@ fn load_transparency_mask_and_composite() {
|
||||
assert_eq!(sel_at(&s, 10, 5), 1.0);
|
||||
}
|
||||
|
||||
/// A white CMYK document (no ink anywhere) whose left half carries some cyan ink.
|
||||
fn cmyk_session(depth: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 20, "mode": "cmyk", "depth": depth, "background": "white"})).unwrap();
|
||||
rect(&mut s, 0, 0, 20, 20, "replace");
|
||||
s.execute("edit.fill", json!({"color": "#00ffff"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_channels_duplicate_and_load_as_tone_not_ink() {
|
||||
// #5787: a CMYK channel is its tone, as the Channels panel draws it: white where there is no ink.
|
||||
// (The merged image goes through RGB, so inked pixels may drift a few levels.)
|
||||
for depth in [8, 16] {
|
||||
let mut s = cmyk_session(depth);
|
||||
let ink = px(&s, 5, 5)[0];
|
||||
assert!(ink > 0.5 && px(&s, 30, 5)[0] == 0.0, "depth {depth}: cyan ink on the left only, {ink}");
|
||||
s.execute("channel.duplicate", json!({"channel": "cyan"})).unwrap();
|
||||
assert_eq!(doc(&s).channels[0].name, "Cyan copy");
|
||||
assert!((chan(&s, 0, 30, 5) - 1.0).abs() < 1e-3, "depth {depth}: no ink duplicates as white");
|
||||
assert!((chan(&s, 0, 5, 5) - (1.0 - ink)).abs() < 0.02, "depth {depth}: ink duplicates dark");
|
||||
s.execute("select.loadSelection", json!({"channel": "cyan"})).unwrap();
|
||||
assert!((sel_at(&s, 30, 5) - 1.0).abs() < 1e-3, "depth {depth}: no ink is selected");
|
||||
assert!((sel_at(&s, 5, 5) - (1.0 - ink)).abs() < 0.02, "depth {depth}");
|
||||
// Calculations read the same tone.
|
||||
let r = s.execute("image.calculations", json!({"source1": {"channel": "cyan"}, "source2": {"channel": "cyan"}, "blending": "normal"})).unwrap();
|
||||
let i = r["channel"].as_u64().unwrap() as usize;
|
||||
assert!((chan(&s, i, 30, 5) - 1.0).abs() < 1e-3, "depth {depth}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_management_and_view_state() {
|
||||
let mut s = session();
|
||||
@@ -627,6 +659,166 @@ fn apply_image_into_alpha_channel() {
|
||||
assert!(chan(&s, 0, 3, 3) < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_apply_image_of_a_colour_channel_keeps_ink_and_tone_apart() {
|
||||
// #5787: the Cyan channel is read as tone and written back as ink, so applying it onto its own
|
||||
// CMYK layer leaves cyan as it was, and into an alpha channel it lands as tone. (The merged
|
||||
// image goes through RGB, so a few levels of drift are allowed.)
|
||||
for depth in [8, 16] {
|
||||
let mut s = cmyk_session(depth);
|
||||
let before = (px(&s, 5, 5), px(&s, 30, 5));
|
||||
s.execute("image.applyImage", json!({"source": {"channel": "cyan"}, "blending": "normal"})).unwrap();
|
||||
let after = px(&s, 5, 5);
|
||||
assert!((after[0] - before.0[0]).abs() < 0.02, "depth {depth}: cyan unchanged, {after:?} vs {:?}", before.0);
|
||||
assert!(px(&s, 30, 5).iter().zip(&before.1).all(|(a, b)| (a - b).abs() < 1e-3), "depth {depth}: no ink stays no ink");
|
||||
s.undo();
|
||||
s.execute("image.applyImage", json!({"blending": "normal"})).unwrap();
|
||||
assert!(px(&s, 5, 5).iter().zip(&before.0).all(|(a, b)| (a - b).abs() < 0.02), "depth {depth}: CMYK onto itself is a no-op");
|
||||
s.execute("channel.new", json!({"fill": "black"})).unwrap();
|
||||
s.execute("image.applyImage", json!({"source": {"channel": "cyan"}, "blending": "normal"})).unwrap();
|
||||
assert!((chan(&s, 0, 30, 5) - 1.0).abs() < 1e-3, "depth {depth}: tone into an alpha channel");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_apply_image_blends_as_tone() {
|
||||
// Photoshop blends a CMYK layer as tone: Multiply (the default) with a white source changes
|
||||
// nothing, and Multiply with Cyan multiplies every channel's tone by Cyan's, so it adds ink.
|
||||
// A spot channel reads as tone too: its solid ink applies as full ink and isn't selected.
|
||||
for depth in [8, 16] {
|
||||
let mut s = cmyk_session(depth);
|
||||
let (left, right) = (px(&s, 5, 5), px(&s, 30, 5));
|
||||
let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01);
|
||||
s.execute("channel.new", json!({"fill": "white"})).unwrap();
|
||||
s.execute("channel.target", json!({"channel": "composite"})).unwrap();
|
||||
s.execute("image.applyImage", json!({"source": {"channel": 0}})).unwrap();
|
||||
assert!(near(&px(&s, 5, 5), &left), "depth {depth}: white multiplies to no change, {:?} vs {left:?}", px(&s, 5, 5));
|
||||
s.undo();
|
||||
s.execute("image.applyImage", json!({"source": {"channel": "cyan"}, "blending": "multiply"})).unwrap();
|
||||
let want: Vec<f32> = left[..4].iter().map(|&ink| 1.0 - (1.0 - ink) * (1.0 - left[0])).chain([1.0]).collect();
|
||||
assert!(near(&px(&s, 5, 5), &want), "depth {depth}: {:?} vs {want:?}", px(&s, 5, 5));
|
||||
assert!(near(&px(&s, 30, 5), &right), "depth {depth}: no ink stays no ink");
|
||||
s.undo();
|
||||
rect(&mut s, 0, 0, 20, 20, "replace");
|
||||
let r = s.execute("channel.newSpot", json!({"color": "#ff00ff", "solidity": 100})).unwrap();
|
||||
let spot = r["channel"].as_u64().unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("channel.target", json!({"channel": "composite"})).unwrap();
|
||||
s.execute("image.applyImage", json!({"source": {"channel": spot}, "blending": "normal"})).unwrap();
|
||||
assert!(near(&px(&s, 5, 5), &[1.0; 5]) && near(&px(&s, 30, 5), &right), "depth {depth}: spot ink applies as ink, {:?}", px(&s, 5, 5));
|
||||
s.execute("select.loadSelection", json!({"channel": spot})).unwrap();
|
||||
assert!(sel_at(&s, 5, 5) < 1e-3 && (sel_at(&s, 30, 5) - 1.0).abs() < 1e-3, "depth {depth}: the spot's unprinted area loads");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_image_onto_a_spot_channel_blends_as_tone() {
|
||||
let near = |a: &[f32], b: &[f32]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01);
|
||||
// #5787: a spot channel stores ink but reads and takes tone, so the spot applied onto itself
|
||||
// changes nothing (Normal or Multiply), and Cyan applied onto it gives it Cyan's ink.
|
||||
for depth in [8, 16] {
|
||||
let mut s = cmyk_session(depth);
|
||||
let cyan = px(&s, 5, 5)[0];
|
||||
rect(&mut s, 0, 0, 10, 20, "replace");
|
||||
let spot = s.execute("channel.newSpot", json!({"color": "#ff00ff", "solidity": 100})).unwrap()["channel"].as_u64().unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let ink = |s: &Session| [5, 15, 30].map(|x| chan(s, spot as usize, x, 5));
|
||||
let before = ink(&s);
|
||||
assert!(near(&before, &[1.0, 0.0, 0.0]), "depth {depth}: {before:?}");
|
||||
for blending in ["normal", "multiply"] {
|
||||
s.execute("image.applyImage", json!({"source": {"channel": spot}, "blending": blending, "target": {"channel": spot}})).unwrap();
|
||||
assert!(near(&ink(&s), &before), "depth {depth}: {blending} onto itself, {:?}", ink(&s));
|
||||
}
|
||||
s.execute("image.applyImage", json!({"source": {"channel": "cyan"}, "blending": "normal", "target": {"channel": spot}})).unwrap();
|
||||
assert!(near(&ink(&s), &[cyan, cyan, 0.0]), "depth {depth}: Cyan's ink, {:?}", ink(&s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_image_from_an_rgb_document_onto_cmyk_reads_it_in_its_own_mode() {
|
||||
let near = |a: &[f32], b: &[f32]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01);
|
||||
// #5787: an RGB composite is tone in RGB terms: white multiplied (the default) onto a CMYK
|
||||
// layer changes nothing.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 20, "background": "white"})).unwrap();
|
||||
s.execute("file.new", json!({"width": 40, "height": 20, "mode": "cmyk", "background": "white"})).unwrap();
|
||||
rect(&mut s, 0, 0, 20, 20, "replace");
|
||||
s.execute("edit.fill", json!({"color": "#00ffff"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let before = (px(&s, 5, 5), px(&s, 30, 5));
|
||||
s.execute("image.applyImage", json!({"source": {"document": 0}})).unwrap();
|
||||
assert!(near(&px(&s, 5, 5), &before.0) && near(&px(&s, 30, 5), &before.1), "{:?} vs {before:?}", (px(&s, 5, 5), px(&s, 30, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_image_of_a_cmyk_composite_onto_a_spot_channel_blends_as_tone() {
|
||||
// #5787: the CMYK composite stores ink, so its white has none: applied in Normal onto an empty
|
||||
// spot channel, the spot stays empty on the white and takes ink on the Cyan.
|
||||
for depth in [8, 16] {
|
||||
let mut s = cmyk_session(depth);
|
||||
let spot = s.execute("channel.newSpot", json!({"color": "#ff00ff", "solidity": 100})).unwrap()["channel"].as_u64().unwrap() as usize;
|
||||
s.execute("image.applyImage", json!({"blending": "normal", "target": {"channel": spot}})).unwrap();
|
||||
let (cyan, white) = (chan(&s, spot, 5, 5), chan(&s, spot, 30, 5));
|
||||
assert!(white < 0.01 && cyan > 0.1 && cyan < 0.9, "depth {depth}: cyan {cyan}, white {white}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_image_of_a_cmyk_composite_onto_rgb_keeps_black() {
|
||||
// #5787: Black alone (no C, M or Y) applied from a CMYK document onto RGB stays black, and
|
||||
// the CMYK white stays white (the merged image goes through RGB, so a few levels of drift).
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 20, "mode": "cmyk", "background": "white"})).unwrap();
|
||||
{
|
||||
let d = s.active_mut().unwrap();
|
||||
let id = d.active_layer.unwrap();
|
||||
let surf = std::sync::Arc::make_mut(&mut d.doc).layer_mut(id).unwrap().surface_mut().unwrap();
|
||||
let n = surf.format().channels();
|
||||
let mut k = vec![0.0; 20 * 20 * n];
|
||||
k.chunks_exact_mut(n).for_each(|p| p[3] = 1.0);
|
||||
surf.write_region(Rect::new(0, 0, 20, 20), &k);
|
||||
}
|
||||
s.execute("file.new", json!({"width": 40, "height": 20, "background": "white"})).unwrap();
|
||||
s.execute("image.applyImage", json!({"source": {"document": 0}, "blending": "normal"})).unwrap();
|
||||
let (black, white) = (px(&s, 5, 5), px(&s, 30, 5));
|
||||
assert!(black[..3].iter().all(|&v| v < 0.05), "{black:?}");
|
||||
assert!(white[..3].iter().all(|&v| v > 0.95), "{white:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_image_of_a_cmyk_composite_onto_an_alpha_channel_takes_its_luminosity() {
|
||||
// #5787: a CMYK composite reaches a channel as its luminosity, as Calculations reads it, so
|
||||
// Black alone stays dark and white stays white.
|
||||
for depth in [8, 16] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 20, "mode": "cmyk", "depth": depth, "background": "white"})).unwrap();
|
||||
{
|
||||
let d = s.active_mut().unwrap();
|
||||
let id = d.active_layer.unwrap();
|
||||
let surf = std::sync::Arc::make_mut(&mut d.doc).layer_mut(id).unwrap().surface_mut().unwrap();
|
||||
let n = surf.format().channels();
|
||||
let mut k = vec![1.0; 20 * 20 * n];
|
||||
k.chunks_exact_mut(n).for_each(|p| p[..3].fill(0.0));
|
||||
surf.write_region(Rect::new(0, 0, 20, 20), &k);
|
||||
}
|
||||
let a = s.execute("channel.new", json!({})).unwrap()["channel"].as_u64().unwrap() as usize;
|
||||
s.execute("image.applyImage", json!({"blending": "normal", "target": {"channel": a}})).unwrap();
|
||||
let b = s.execute("image.calculations", json!({"source1": {}, "source2": {}, "blending": "normal"})).unwrap()["channel"].as_u64().unwrap() as usize;
|
||||
let (black, white) = (chan(&s, a, 5, 5), chan(&s, a, 30, 5));
|
||||
assert!(black < 0.25 && (white - 1.0).abs() < 1e-3, "depth {depth}: black {black}, white {white}");
|
||||
assert!((black - chan(&s, b, 5, 5)).abs() < 1e-3, "depth {depth}: as Calculations reads it");
|
||||
// A targeted layer mask is a grayscale target too: it takes the same luminosity.
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
s.execute("channel.target", json!({"channel": "mask"})).unwrap();
|
||||
s.execute("image.applyImage", json!({"blending": "normal"})).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
let m = &d.doc.layer(d.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface;
|
||||
let (mb, mw) = (m.sample_channel(5, 5, 0), m.sample_channel(30, 5, 0));
|
||||
assert!((mb - black).abs() < 1e-3 && (mw - 1.0).abs() < 1e-3, "depth {depth}: mask black {mb}, white {mw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_image_into_targeted_layer_mask_leaves_the_pixels() {
|
||||
let mut s = session();
|
||||
@@ -706,6 +898,30 @@ fn split_and_merge_channels() {
|
||||
assert!(s.execute("channel.merge", json!({"mode": "cmyk"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_split_and_merge_use_tone() {
|
||||
// #5787: Split Channels of a white CMYK image gives white grayscale documents, and merging
|
||||
// them back gives the same CMYK pixels (within the few levels the merged image's trip
|
||||
// through RGB costs).
|
||||
for depth in [8, 16] {
|
||||
let mut s = cmyk_session(depth);
|
||||
let (left, right) = (px(&s, 5, 5), px(&s, 30, 5));
|
||||
s.execute("channel.split", json!({})).unwrap();
|
||||
assert_eq!(s.documents().len(), 4);
|
||||
let v = |s: &Session, i: usize, x: i32| s.documents()[i].doc.layers[0].surface().unwrap().pixel(x, 5)[0];
|
||||
for i in 0..4 {
|
||||
assert!((v(&s, i, 30) - 1.0).abs() < 1e-3, "depth {depth}: channel {i} of white is white");
|
||||
}
|
||||
assert!((v(&s, 0, 5) - (1.0 - left[0])).abs() < 0.02, "depth {depth}: cyan ink splits dark");
|
||||
s.set_active(0);
|
||||
s.execute("channel.merge", json!({"mode": "cmyk", "documents": [0, 1, 2, 3]})).unwrap();
|
||||
assert_eq!(doc(&s).mode, ColorMode::Cmyk);
|
||||
let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.02);
|
||||
assert!(near(&px(&s, 5, 5), &left), "depth {depth}: round trip {:?} vs {left:?}", px(&s, 5, 5));
|
||||
assert!(near(&px(&s, 30, 5), &right), "depth {depth}: white stays white {:?}", px(&s, 30, 5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_rejects_a_repeated_source_and_keeps_unrelated_documents() {
|
||||
// #934: `documents: [0, 0, 1]` passed validation, then the close loop ran `close(1)`,
|
||||
|
||||
@@ -520,8 +520,16 @@ pub(crate) fn paste_to_target(s: &mut Session, p: &Value, in_place: bool, limit:
|
||||
let src = shifted(&clip.surface, dx, dy);
|
||||
let area = src.content_bounds().intersect(&canvas);
|
||||
s.edit(label, |doc, _| {
|
||||
// A spot channel stores ink: the paste goes over its tone, then back to ink (#5787).
|
||||
let spot = crate::channel_cmds::is_spot_target(doc, p);
|
||||
let (surf, _) = crate::channel_cmds::target_surface(doc, layer, p)?;
|
||||
if spot {
|
||||
crate::channel_cmds::invert_region(surf, area)?;
|
||||
}
|
||||
crate::fill_cmds::composite_over(surf, &src, area, limit)?;
|
||||
if spot {
|
||||
crate::channel_cmds::invert_region(surf, area)?;
|
||||
}
|
||||
surf.prune();
|
||||
doc.selection = None;
|
||||
Ok(())
|
||||
@@ -775,11 +783,8 @@ fn transform_again_copy(s: &mut Session) -> Result<Value> {
|
||||
Err(e) => {
|
||||
// The copy (e.g. of a layer whose position is locked) couldn't be transformed: take
|
||||
// it back, leaving nothing to redo and no Duplicate step to record.
|
||||
s.undo();
|
||||
s.rollback();
|
||||
s.journal.truncate(journal);
|
||||
if let Some(st) = s.active_mut() {
|
||||
st.history.clear_redo();
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1145,6 +1145,31 @@ fn transform_again_on_a_copy_steps_and_repeats() {
|
||||
assert!(!s.active().unwrap().history.can_redo(), "nothing to redo");
|
||||
}
|
||||
|
||||
/// History Options › Allow Non-Linear History: the Duplicate step of a refused Transform
|
||||
/// Again on a copy moved the later states in front of it; taking it back must restore the
|
||||
/// document before the command and the History list as it was, not land on a later state.
|
||||
#[test]
|
||||
fn a_refused_transform_again_on_a_copy_rolls_back_exactly_in_non_linear_history() {
|
||||
let mut s = session();
|
||||
s.execute("history.options", json!({"allowNonLinearHistory": true})).unwrap();
|
||||
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 10, 0]})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": id.0, "locks": {"position": true}})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": id.0, "opacity": 0.5})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": id.0, "opacity": 0.25})).unwrap();
|
||||
s.undo();
|
||||
s.undo();
|
||||
let st = s.active().unwrap();
|
||||
let (doc, entries) = (st.doc.clone(), st.history.entries());
|
||||
let redo: Vec<String> = st.history.redo_labels().map(str::to_string).collect();
|
||||
assert_eq!(redo.len(), 2);
|
||||
assert!(s.execute("edit.transform.againCopy", json!({})).unwrap_err().to_string().contains("locked"));
|
||||
let st = s.active().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&st.doc, &doc), "the document before the command");
|
||||
assert_eq!(st.history.entries(), entries);
|
||||
assert_eq!(st.history.redo_labels().map(str::to_string).collect::<Vec<_>>(), redo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_last_state_and_transform_again() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -583,7 +583,7 @@ fn set_visible(s: &mut Session, p: &Value, visible: bool) -> Result<Value> {
|
||||
return crate::layer_multi_cmds::set_visible_selected(s, visible);
|
||||
}
|
||||
let id = layer_param(s, p)?;
|
||||
s.edit(if visible { "Show Layer" } else { "Hide Layer" }, |doc, _| {
|
||||
s.edit_visibility(if visible { "Show Layer" } else { "Hide Layer" }, |doc, _| {
|
||||
doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.visible = visible;
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -649,7 +649,7 @@ fn show_only(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let rows = walk.iter().map(|(p, _, l)| (p.clone(), before.as_ref().is_none_or(|b| b.get(&l.id).copied().unwrap_or(l.visible)))).collect();
|
||||
("Show Layers", rows, None)
|
||||
};
|
||||
s.edit(label, |doc, _| {
|
||||
s.edit_visibility(label, |doc, _| {
|
||||
for (p, visible) in &rows {
|
||||
if let Some(l) = doc.layer_at_mut(p) {
|
||||
l.visible = *visible;
|
||||
|
||||
@@ -54,11 +54,8 @@ fn frame_from_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Err(e) => {
|
||||
// The group couldn't be framed: take the grouping back, leaving nothing to redo and
|
||||
// no grouping step to record.
|
||||
s.undo();
|
||||
s.rollback();
|
||||
s.journal.truncate(journal);
|
||||
if let Some(st) = s.active_mut() {
|
||||
st.history.clear_redo();
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,27 +39,41 @@ fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
/// The `state` param as an index into the active document's history entries, defaulting to the
|
||||
/// current state.
|
||||
fn state_index(s: &Session, cmd: &str, p: &Value) -> Result<(usize, usize)> {
|
||||
state_index_upto(s, cmd, p, false)
|
||||
}
|
||||
|
||||
/// [`state_index`], also accepting the later (dimmed, redo) states with `later`.
|
||||
fn state_index_upto(s: &Session, cmd: &str, p: &Value, later: bool) -> Result<(usize, usize)> {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let current = st.history.past_len();
|
||||
let last = if later { current.saturating_add(st.history.redo_labels().count()) } else { current };
|
||||
let i = match p.get("state") {
|
||||
None | Some(Value::Null) => current,
|
||||
Some(_) => {
|
||||
let v = int(p, "state").ok_or_else(|| bad(cmd, "`state` must be a number"))?;
|
||||
usize::try_from(v).ok().filter(|i| *i <= current).ok_or_else(|| bad(cmd, format!("`state` = {v} is not a state (0..={current})")))?
|
||||
usize::try_from(v).ok().filter(|i| *i <= last).ok_or_else(|| bad(cmd, format!("`state` = {v} is not a state (0..={last})")))?
|
||||
}
|
||||
};
|
||||
Ok((i, current))
|
||||
}
|
||||
|
||||
/// History panel › Delete: drop the state and every state after it, as Photoshop does in linear
|
||||
/// history. The document returns to the state before it, and the deleted steps can't be redone.
|
||||
/// The oldest state (the document as opened) can't be deleted.
|
||||
/// history (in non-linear history only that state, also a later one:
|
||||
/// `history_options_cmds::delete_state`). The document returns to the state before it, and the
|
||||
/// deleted steps can't be redone. The oldest state (the document as opened) can't be deleted.
|
||||
fn delete_state(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "history.deleteState";
|
||||
let (i, current) = state_index(s, CMD, p)?;
|
||||
// History Options › Allow Non-Linear History: only that state goes, a later (dimmed) one too.
|
||||
let non_linear = s.active().is_some_and(|st| st.history.non_linear);
|
||||
let (i, current) = state_index_upto(s, CMD, p, non_linear)?;
|
||||
if i == 0 {
|
||||
return Err(bad(CMD, "the oldest state can't be deleted"));
|
||||
}
|
||||
if non_linear {
|
||||
crate::history_options_cmds::delete_state(s, i, current)?;
|
||||
let entries = s.active().ok_or(EngineError::NoDocument)?.history.entries();
|
||||
return Ok(json!({ "states": entries.len(), "current": entries.last() }));
|
||||
}
|
||||
// Step back to the state before `i`; the steps from `i` on are then redo states.
|
||||
for _ in i..=current {
|
||||
if !s.undo() {
|
||||
@@ -199,8 +213,13 @@ fn delete_snapshot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "snapshot": snap.id, "snapshots": st.snapshots.len() }))
|
||||
}
|
||||
|
||||
/// A state other than the oldest exists to delete: a past or the current one, and with Allow
|
||||
/// Non-Linear History a later (dimmed) one too, also while the oldest state is current.
|
||||
fn can_delete(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().filter(|d| d.history.past_len() > 0).map(|_| ()).ok_or_else(|| "the oldest state can't be deleted".into())
|
||||
s.active()
|
||||
.filter(|d| d.history.past_len() > 0 || (d.history.non_linear && d.history.can_redo()))
|
||||
.map(|_| ())
|
||||
.ok_or_else(|| "the oldest state can't be deleted".into())
|
||||
}
|
||||
|
||||
fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -245,7 +264,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Delete State",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"state":index?} (History entry, 0 = oldest; default the current state) → {states,current}: removes the state and every later one"##,
|
||||
params: r##"{"state":index?} (History entry, 0 = oldest; default the current state) → {states,current}: removes the state and every later one (with Allow Non-Linear History only that state, a later dimmed one too)"##,
|
||||
enabled: can_delete,
|
||||
run: delete_state,
|
||||
journal: true,
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
//! History panel › History Options… (#2966): Allow Non-Linear History and Make Layer Visibility
|
||||
//! Changes Undoable. The values are preferences (`historyOptions.*`, also `prefs.set`), applied
|
||||
//! by [`Session::apply_prefs`] and [`Session::edit_visibility`]. Without params the command
|
||||
//! reports them; the GUI opens its dialog instead and runs it with the dialog's values on OK.
|
||||
//!
|
||||
//! Photoshop's three snapshot options (Automatically Create First Snapshot, Automatically
|
||||
//! Create New Snapshot When Saving, Show New Snapshot Dialog by Default) come with #3634, which
|
||||
//! makes the snapshots act on them.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "history.options";
|
||||
|
||||
/// The option keys, as in `prefs.get historyOptions` and the command's params.
|
||||
pub const OPTIONS: [&str; 2] = ["allowNonLinearHistory", "makeLayerVisibilityChangesUndoable"];
|
||||
|
||||
fn bad(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
/// The current History Options as `{allowNonLinearHistory, makeLayerVisibilityChangesUndoable}`.
|
||||
pub fn current(s: &Session) -> Value {
|
||||
let o = &s.prefs().history_options;
|
||||
serde_json::json!({
|
||||
"allowNonLinearHistory": o.allow_non_linear_history,
|
||||
"makeLayerVisibilityChangesUndoable": o.make_layer_visibility_changes_undoable,
|
||||
})
|
||||
}
|
||||
|
||||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let given: Map<String, Value> = match p {
|
||||
Value::Null => Map::new(),
|
||||
Value::Object(m) => m.clone(),
|
||||
_ => return Err(bad("params must be an object")),
|
||||
};
|
||||
// Validate everything before changing anything.
|
||||
let mut set = Vec::new();
|
||||
for (k, v) in &given {
|
||||
if !OPTIONS.contains(&k.as_str()) {
|
||||
return Err(bad(format!("unknown option `{k}` (expected {})", OPTIONS.join(", "))));
|
||||
}
|
||||
let b = v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false")))?;
|
||||
set.push((k.clone(), b));
|
||||
}
|
||||
if !set.is_empty() {
|
||||
s.edit_prefs(|prefs| {
|
||||
for (k, b) in &set {
|
||||
match k.as_str() {
|
||||
"allowNonLinearHistory" => prefs.history_options.allow_non_linear_history = *b,
|
||||
_ => prefs.history_options.make_layer_visibility_changes_undoable = *b,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(current(s))
|
||||
}
|
||||
|
||||
/// History panel › Delete in non-linear history: delete state `i` (numbered as the History panel
|
||||
/// lists them, `current` = the current state's index, the later dimmed states after it) and
|
||||
/// nothing else. Deleting the current state steps back to the one before it; the later states
|
||||
/// stay.
|
||||
pub(crate) fn delete_state(s: &mut Session, i: usize, current: usize) -> Result<()> {
|
||||
if i == 0 {
|
||||
return Err(EngineError::BadParams { cmd: "history.deleteState".into(), msg: "the oldest state can't be deleted".into() });
|
||||
}
|
||||
// A background job is computing from the current state: it must not move under it.
|
||||
if s.active_job().is_some() {
|
||||
return Err(EngineError::Other("the history can't step back right now".into()));
|
||||
}
|
||||
let keep_visibility = !s.visibility_undoable();
|
||||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
st.coalesce = None;
|
||||
if i != current {
|
||||
let removed = match i.checked_sub(current.saturating_add(1)) {
|
||||
Some(j) => st.history.remove_later(j),
|
||||
None => st.history.remove(i),
|
||||
};
|
||||
if !removed {
|
||||
return Err(EngineError::Other(format!("state {i} is not held")));
|
||||
}
|
||||
// The document is unchanged, but the History panel's rows are not: a view-only change
|
||||
// that keeps a clean document clean.
|
||||
let clean = st.saved_revision == st.revision;
|
||||
st.revision += 1;
|
||||
st.view_revision += 1;
|
||||
if clean {
|
||||
st.saved_revision = st.revision;
|
||||
}
|
||||
st.last_damage = Some(photocraft_geom::Rect::EMPTY);
|
||||
return Ok(());
|
||||
}
|
||||
// The step that made the current state: the layers whose visibility it changed get it back.
|
||||
let changed = st.history.current_visibility().to_vec();
|
||||
let (mut d, layers) = st.history.drop_current().ok_or_else(|| EngineError::Other("the history can't step back right now".into()))?;
|
||||
// As Undo does: Save As changes file identity outside history.
|
||||
if d.name != st.doc.name {
|
||||
std::sync::Arc::make_mut(&mut d).name.clone_from(&st.doc.name);
|
||||
}
|
||||
if keep_visibility {
|
||||
crate::carry_visibility(&st.doc, &mut d, &changed);
|
||||
}
|
||||
let damage = crate::layer_multi_cmds::step_damage(&d, &st.doc);
|
||||
st.doc = d;
|
||||
crate::restore_target(st, layers);
|
||||
st.revision += 1;
|
||||
st.last_damage = damage;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec {
|
||||
id: CMD,
|
||||
label: "History Options…",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"allowNonLinearHistory":bool?,"makeLayerVisibilityChangesUndoable":bool?} → both values (no params: report them). Allow Non-Linear History: an edit after stepping back keeps the later states and is appended at the end; deleting a state deletes only it (default off). Make Layer Visibility Changes Undoable: showing or hiding a layer is a history state (default on). Also `prefs.set historyOptions.*`"##,
|
||||
enabled: always,
|
||||
run: options,
|
||||
journal: false,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn entries(s: &Session) -> Vec<String> {
|
||||
s.active().unwrap().history.entries()
|
||||
}
|
||||
|
||||
/// A document with a layer above the background: entries Open, New Layer.
|
||||
fn doc() -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap().0;
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_photoshop_and_round_trip_through_prefs() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.execute(CMD, json!({})).unwrap(), json!({"allowNonLinearHistory": false, "makeLayerVisibilityChangesUndoable": true}));
|
||||
assert_eq!(s.execute("prefs.get", json!({"path": "historyOptions"})).unwrap(), current(&s));
|
||||
let r = s.execute(CMD, json!({"allowNonLinearHistory": true, "makeLayerVisibilityChangesUndoable": false})).unwrap();
|
||||
assert_eq!(r, json!({"allowNonLinearHistory": true, "makeLayerVisibilityChangesUndoable": false}));
|
||||
assert_eq!(s.execute("prefs.get", json!({"path": "historyOptions.allowNonLinearHistory"})).unwrap(), json!(true));
|
||||
// Saved and restored with the other preferences.
|
||||
let saved = s.prefs_to_json();
|
||||
let mut t = Session::new();
|
||||
t.load_prefs_json(&saved).unwrap();
|
||||
assert_eq!(current(&t), r);
|
||||
// prefs.set and prefs.reset reach them too.
|
||||
t.execute("prefs.set", json!({"path": "historyOptions.allowNonLinearHistory", "value": false})).unwrap();
|
||||
assert_eq!(t.execute(CMD, Value::Null).unwrap()["allowNonLinearHistory"], json!(false));
|
||||
t.execute("prefs.reset", json!({"path": "historyOptions"})).unwrap();
|
||||
assert_eq!(current(&t), current(&Session::new()));
|
||||
// Older preference files without the section load with the defaults.
|
||||
t.load_prefs_json("{}").unwrap();
|
||||
assert_eq!(current(&t), current(&Session::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_refused_and_change_nothing() {
|
||||
let mut s = Session::new();
|
||||
let before = current(&s);
|
||||
for p in [
|
||||
json!({"allowNonLinearHistory": "yes"}),
|
||||
json!({"allowNonLinearHistory": 1}),
|
||||
json!({"makeLayerVisibilityChangesUndoable": null}),
|
||||
json!({"allowNonLinearHistory": true, "snapshots": true}),
|
||||
json!([true]),
|
||||
json!("x"),
|
||||
json!(3),
|
||||
] {
|
||||
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
|
||||
assert_eq!(current(&s), before, "{p} changed nothing");
|
||||
}
|
||||
assert!(s.execute("prefs.set", json!({"path": "historyOptions.allowNonLinearHistory", "value": "on"})).is_err());
|
||||
assert_eq!(current(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_visibility_is_a_history_state_only_when_undoable() {
|
||||
// On (the default): the eye, Hide/Show Layers and ⌥-click each add a state.
|
||||
let (mut s, id) = doc();
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap();
|
||||
assert_eq!(entries(&s).last().map(String::as_str), Some("Layer Visibility"));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().visible);
|
||||
|
||||
// Off: the document changes (and is modified) but History doesn't.
|
||||
let (mut s, id) = doc();
|
||||
s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap();
|
||||
let before = entries(&s);
|
||||
let rev = s.active().unwrap().revision;
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap();
|
||||
s.execute("layer.showLayers", json!({"layer": id})).unwrap();
|
||||
s.execute("layer.hideLayers", json!({"layer": id})).unwrap();
|
||||
s.execute("layer.showOnly", json!({"layer": id})).unwrap();
|
||||
s.execute("layer.showOnly", json!({"layer": id})).unwrap();
|
||||
// Hide Layers toggles (⌘,), so show the layer first for it to hide.
|
||||
s.execute("layer.showLayers", json!({"layer": id})).unwrap();
|
||||
s.execute("layer.hideLayers", json!({"layer": id})).unwrap();
|
||||
assert_eq!(entries(&s), before, "no visibility state was recorded");
|
||||
let st = s.active().unwrap();
|
||||
assert!(!st.doc.layer(photocraft_doc::LayerId(id)).unwrap().visible, "the change still applies");
|
||||
assert!(st.revision > rev && st.is_dirty());
|
||||
// Other properties still record a state.
|
||||
s.execute("layer.setProps", json!({"layer": id, "opacity": 0.5})).unwrap();
|
||||
assert_eq!(entries(&s).len(), before.len() + 1);
|
||||
// An unrecorded change leaves the redo states alone.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": true})).unwrap();
|
||||
assert!(s.active().unwrap().history.can_redo());
|
||||
}
|
||||
|
||||
fn visible(s: &Session, id: u64) -> bool {
|
||||
s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().visible
|
||||
}
|
||||
|
||||
fn red(s: &Session, id: u64) -> bool {
|
||||
let st = s.active().unwrap();
|
||||
st.doc.layer(photocraft_doc::LayerId(id)).unwrap().surface().unwrap().pixel(1, 1)[0] > 0.5
|
||||
}
|
||||
|
||||
/// Off, showing or hiding a layer is not a state, so stepping through history (Undo, Redo, a
|
||||
/// History row) leaves the visibility as the user set it, as Photoshop does; history states
|
||||
/// hold whole documents and would otherwise bring the old visibility back.
|
||||
#[test]
|
||||
fn stepping_through_history_keeps_unrecorded_visibility() {
|
||||
let (mut s, id) = doc();
|
||||
s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap();
|
||||
assert!(s.execute("edit.undo", json!({})).unwrap().as_bool().unwrap());
|
||||
assert!(!red(&s, id), "the fill is undone");
|
||||
assert!(!visible(&s, id), "the layer stays hidden");
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": true})).unwrap();
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert!(red(&s, id) && visible(&s, id), "redo brings the fill back, not the hidden layer");
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap();
|
||||
// History rows: New, New Layer, Fill.
|
||||
s.execute("history.selectState", json!({"state": 1})).unwrap();
|
||||
assert!(!red(&s, id) && !visible(&s, id));
|
||||
s.execute("history.selectState", json!({"state": 2})).unwrap();
|
||||
assert!(red(&s, id) && !visible(&s, id));
|
||||
|
||||
// On (the default): Undo takes the hide back first, then the fill.
|
||||
let (mut s, id) = doc();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(red(&s, id) && visible(&s, id));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(!red(&s, id) && visible(&s, id));
|
||||
s.execute("history.selectState", json!({"state": 3})).unwrap();
|
||||
assert!(red(&s, id) && !visible(&s, id), "the hide is a state of its own");
|
||||
}
|
||||
|
||||
/// Off, only the unrecorded visibility changes carry across history: a recorded step that
|
||||
/// changed visibility (Apply Layer Comp) gives it back when undone and sets it again when
|
||||
/// redone, whether by Undo / Redo, a History row or deleting the current state.
|
||||
#[test]
|
||||
fn stepping_over_a_recorded_visibility_change_restores_it() {
|
||||
let (mut s, id) = doc();
|
||||
s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap();
|
||||
// A comp with the layer hidden, then the eye shows it again (no state) and a fill.
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap();
|
||||
s.execute("layerComp.new", json!({"name": "Hidden"})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": true})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("layerComp.apply", json!({"comp": "Hidden"})).unwrap();
|
||||
assert!(red(&s, id) && !visible(&s, id), "the comp hides the layer");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(red(&s, id) && visible(&s, id), "undoing Apply Layer Comp shows the layer again");
|
||||
// The eye hides it (no state): undoing the fill keeps that.
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(!red(&s, id) && !visible(&s, id), "the unrecorded hide carries across the fill's undo");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert!(red(&s, id) && !visible(&s, id));
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": true})).unwrap();
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert!(red(&s, id) && !visible(&s, id), "redoing the comp hides the layer again");
|
||||
|
||||
// History rows: New, New Layer, New Layer Comp, Fill, Apply Layer Comp.
|
||||
assert_eq!(entries(&s).len(), 5);
|
||||
s.execute("history.selectState", json!({"state": 2})).unwrap();
|
||||
assert!(!red(&s, id) && visible(&s, id), "jumping back over the comp shows the layer again");
|
||||
s.execute("history.selectState", json!({"state": 4})).unwrap();
|
||||
assert!(red(&s, id) && !visible(&s, id), "jumping forward over the comp hides it again");
|
||||
|
||||
// Non-linear Delete of the current state (the comp) steps back and shows the layer too.
|
||||
s.execute(CMD, json!({"allowNonLinearHistory": true})).unwrap();
|
||||
s.execute("history.deleteState", json!({})).unwrap();
|
||||
assert!(red(&s, id) && visible(&s, id), "deleting the comp's state shows the layer again");
|
||||
|
||||
// Non-linear Delete of a past state: the step after it now starts from the state before
|
||||
// it, so undoing that step also gives back the visibility the deleted step changed.
|
||||
s.execute("layerComp.apply", json!({"comp": "Hidden"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
|
||||
let comp = entries(&s).len() - 2;
|
||||
s.execute("history.deleteState", json!({"state": comp})).unwrap();
|
||||
assert!(!visible(&s, id));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(red(&s, id) && visible(&s, id), "back at the red fill, where the layer was shown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_linear_history_keeps_later_states_and_delete_removes_only_one() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
|
||||
s.execute(CMD, json!({"allowNonLinearHistory": true})).unwrap();
|
||||
for c in ["#ff0000", "#00ff00", "#0000ff"] {
|
||||
s.execute("edit.fill", json!({"color": c})).unwrap();
|
||||
}
|
||||
let filled = entries(&s);
|
||||
assert_eq!(filled.len(), 4);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("image.adjustments.invert", json!({})).unwrap();
|
||||
let after = entries(&s);
|
||||
assert_eq!(after.len(), 5, "the two later fills are kept: {after:?}");
|
||||
assert_eq!(after[..4], filled[..]);
|
||||
// Delete the second fill only: the states after it stay.
|
||||
s.execute("history.deleteState", json!({"state": 2})).unwrap();
|
||||
let mut expected = after.clone();
|
||||
expected.remove(2);
|
||||
assert_eq!(entries(&s), expected);
|
||||
// Deleting the current state steps back to the one before; nothing else goes.
|
||||
let doc_before = s.active().unwrap().history.state(expected.len() - 2).unwrap();
|
||||
s.execute("history.deleteState", json!({})).unwrap();
|
||||
assert_eq!(entries(&s), expected[..expected.len() - 1].to_vec());
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &doc_before));
|
||||
// After stepping back, deleting the current state keeps the later (redo) states.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let redo: Vec<String> = s.active().unwrap().history.redo_labels().map(str::to_string).collect();
|
||||
s.execute("history.deleteState", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.redo_labels().map(str::to_string).collect::<Vec<_>>(), redo);
|
||||
|
||||
// Turning it off applies to open documents: the next edit after stepping back discards.
|
||||
s.execute(CMD, json!({"allowNonLinearHistory": false})).unwrap();
|
||||
assert!(!s.active().unwrap().history.non_linear);
|
||||
s.execute("edit.fill", json!({"color": "#808080"})).unwrap();
|
||||
assert!(!s.active().unwrap().history.can_redo());
|
||||
// New documents pick the option up too.
|
||||
s.execute(CMD, json!({"allowNonLinearHistory": true})).unwrap();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
assert!(s.active().unwrap().history.non_linear);
|
||||
}
|
||||
|
||||
/// With the oldest state current, non-linear Delete is still enabled for a later (dimmed)
|
||||
/// state (the History panel's trash and its drop target ask for it); linear history has
|
||||
/// nothing to delete there.
|
||||
#[test]
|
||||
fn non_linear_delete_is_enabled_while_the_oldest_state_is_current() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
|
||||
for c in ["#ff0000", "#00ff00"] {
|
||||
s.execute("edit.fill", json!({"color": c})).unwrap();
|
||||
}
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(!s.is_enabled("history.deleteState"), "linear: only the oldest state is left to delete");
|
||||
s.execute(CMD, json!({"allowNonLinearHistory": true})).unwrap();
|
||||
assert!(s.is_enabled("history.deleteState"));
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
s.execute("history.deleteState", json!({"state": 1})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&st.doc, &doc), "the document is unchanged");
|
||||
assert_eq!(st.history.redo_labels().count(), 1, "only the first fill went");
|
||||
assert!(s.execute("history.deleteState", json!({})).is_err(), "the oldest state itself stays");
|
||||
s.execute("history.deleteState", json!({"state": 1})).unwrap();
|
||||
assert!(!s.is_enabled("history.deleteState"), "nothing left but the oldest state");
|
||||
}
|
||||
|
||||
/// Non-linear Delete of a past or later state leaves the document as it is, so a saved
|
||||
/// (clean) document stays clean; deleting the current state steps back and modifies it.
|
||||
#[test]
|
||||
fn non_linear_delete_of_another_state_keeps_a_clean_document_clean() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
|
||||
s.execute(CMD, json!({"allowNonLinearHistory": true})).unwrap();
|
||||
for c in ["#ff0000", "#00ff00", "#0000ff", "#808080"] {
|
||||
s.execute("edit.fill", json!({"color": c})).unwrap();
|
||||
}
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// States: New, Fill 1-3 (3 current), the dimmed Fill 4. As if just saved.
|
||||
let st = s.active_mut().unwrap();
|
||||
st.saved_revision = st.revision;
|
||||
let rev = st.revision;
|
||||
s.execute("history.deleteState", json!({"state": 1})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
assert!(!st.is_dirty(), "deleting a past state left the saved document unmodified");
|
||||
assert!(st.revision > rev, "the History panel still sees the change");
|
||||
s.execute("history.deleteState", json!({"state": 3})).unwrap();
|
||||
assert!(!s.active().unwrap().is_dirty(), "deleting a later state left it unmodified too");
|
||||
assert_eq!(s.active().unwrap().history.redo_labels().count(), 0);
|
||||
// A modified document stays modified.
|
||||
s.execute("edit.fill", json!({"color": "#ffffff"})).unwrap();
|
||||
s.execute("history.deleteState", json!({"state": 1})).unwrap();
|
||||
assert!(s.active().unwrap().is_dirty());
|
||||
// The current state: the document steps back, which is a change.
|
||||
let st = s.active_mut().unwrap();
|
||||
st.saved_revision = st.revision;
|
||||
s.execute("history.deleteState", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
/// With the option off, a recorded step that changes visibility along with other properties
|
||||
/// (layer.setProps visible + opacity is one Layer Properties state) is undone and redone as a
|
||||
/// whole: the carried-over visibility skips the layers the step itself changed.
|
||||
#[test]
|
||||
fn undoing_a_mixed_visibility_and_opacity_change_undoes_both() {
|
||||
let (mut s, id) = doc();
|
||||
s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap();
|
||||
let n = entries(&s).len();
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": false, "opacity": 0.5})).unwrap();
|
||||
assert_eq!(entries(&s).len(), n + 1, "the mixed change is a state");
|
||||
let opacity = |s: &Session| s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().opacity;
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(visible(&s, id) && (opacity(&s) - 1.0).abs() < 1e-6, "undo shows the layer again and restores its opacity");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert!(!visible(&s, id) && (opacity(&s) - 0.5).abs() < 1e-6, "redo hides it again");
|
||||
}
|
||||
|
||||
/// Non-linear Delete reaches a later (dimmed) state too, and deletes only it; linear history
|
||||
/// still refuses one past the current state.
|
||||
#[test]
|
||||
fn non_linear_delete_removes_a_later_state() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
|
||||
for c in ["#ff0000", "#00ff00", "#0000ff"] {
|
||||
s.execute("edit.fill", json!({"color": c})).unwrap();
|
||||
}
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.execute("history.deleteState", json!({"state": 2})).is_err(), "linear: not a deletable state");
|
||||
s.execute(CMD, json!({"allowNonLinearHistory": true})).unwrap();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let mut expected = entries(&s);
|
||||
let mut redo: Vec<String> = s.active().unwrap().history.redo_labels().map(str::to_string).collect();
|
||||
// Entries: New, Fill (current), then the dimmed Fill 2 (state 2) and Fill 3 (state 3).
|
||||
s.execute("history.deleteState", json!({"state": 2})).unwrap();
|
||||
redo.remove(0);
|
||||
let st = s.active().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&st.doc, &doc), "the document is unchanged");
|
||||
assert_eq!(st.history.redo_labels().map(str::to_string).collect::<Vec<_>>(), redo);
|
||||
assert_eq!(entries(&s), expected);
|
||||
assert!(s.execute("history.deleteState", json!({"state": 4})).is_err(), "past the last state");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
expected.push(redo[0].clone());
|
||||
assert_eq!(entries(&s), expected);
|
||||
let px = s.active().unwrap().doc.layers[0].surface().unwrap().pixel(1, 1);
|
||||
assert!(px[2] > 0.5 && px[1] < 0.5, "redo reaches the blue fill: {px:?}");
|
||||
}
|
||||
|
||||
/// Off, taking a step back with `Session::rollback` (a cancelled Free Transform of a made
|
||||
/// layer, an abandoned Alt-drag copy, a compound command whose later part failed) keeps the
|
||||
/// unrecorded visibility as Undo does, and gives back the visibility the step itself changed,
|
||||
/// whether it takes the step back exactly, falls back to Undo or lands on the oldest state.
|
||||
#[test]
|
||||
fn rollback_keeps_unrecorded_visibility() {
|
||||
// Exactly: the fill is taken back, the eye's hide stays.
|
||||
let (mut s, id) = doc();
|
||||
s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap();
|
||||
assert!(s.rollback());
|
||||
assert!(!red(&s, id), "the fill is taken back");
|
||||
assert!(!visible(&s, id), "the unrecorded hide stays");
|
||||
assert!(!s.active().unwrap().history.can_redo(), "nothing to redo");
|
||||
|
||||
// Landing on the oldest state (the New Layer step taken back): the hidden background stays
|
||||
// hidden.
|
||||
let (mut s, _) = doc();
|
||||
s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap();
|
||||
let bg = s.active().unwrap().doc.layers[0].id.0;
|
||||
s.execute("layer.setProps", json!({"layer": bg, "visible": false})).unwrap();
|
||||
assert!(s.rollback());
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), 1, "the new layer is taken back");
|
||||
assert!(!visible(&s, bg), "the unrecorded hide stays on the oldest state");
|
||||
assert!(!s.rollback(), "nothing left to take back");
|
||||
assert!(!visible(&s, bg));
|
||||
|
||||
// A step that changed visibility (Apply Layer Comp) gives it back.
|
||||
let (mut s, id) = doc();
|
||||
s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap();
|
||||
s.execute("layerComp.new", json!({"name": "Hidden"})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": true})).unwrap();
|
||||
s.execute("layerComp.apply", json!({"comp": "Hidden"})).unwrap();
|
||||
assert!(!visible(&s, id));
|
||||
assert!(s.rollback());
|
||||
assert!(visible(&s, id), "taking back Apply Layer Comp shows the layer again");
|
||||
|
||||
// Falling back to Undo (the history changed since the fill: undo, redo).
|
||||
let (mut s, id) = doc();
|
||||
s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap();
|
||||
assert!(s.rollback());
|
||||
assert!(!red(&s, id) && !visible(&s, id), "the fallback Undo keeps the hide too");
|
||||
assert!(!s.active().unwrap().history.can_redo());
|
||||
|
||||
// Non-linear: a step recorded after stepping back, taken back exactly, keeps the hide and
|
||||
// gives the later state back to redo.
|
||||
let (mut s, id) = doc();
|
||||
s.execute(CMD, json!({"makeLayerVisibilityChangesUndoable": false, "allowNonLinearHistory": true})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("image.adjustments.invert", json!({})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap();
|
||||
assert!(s.rollback());
|
||||
assert!(!red(&s, id) && !visible(&s, id), "back before the invert, still hidden");
|
||||
assert!(s.active().unwrap().history.can_redo(), "the fill is redoable again");
|
||||
|
||||
// On (the default) the eye records a state of its own, which a rollback takes back.
|
||||
let (mut s, id) = doc();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": id, "visible": false})).unwrap();
|
||||
assert!(s.rollback());
|
||||
assert!(red(&s, id) && visible(&s, id), "the hide is taken back, the fill stays");
|
||||
}
|
||||
|
||||
/// Non-linear, a step that moves more later states than the History States limit leaves room
|
||||
/// for is still taken back exactly by a rollback (a compound command whose later part failed):
|
||||
/// the document before it comes back with the full list, not an Undo onto the last later
|
||||
/// state that also drops the rest.
|
||||
#[test]
|
||||
fn rollback_past_the_state_limit_restores_the_list() {
|
||||
let (mut s, id) = doc();
|
||||
s.execute(CMD, json!({"allowNonLinearHistory": true})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("image.adjustments.invert", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let before = entries(&s);
|
||||
let redo: Vec<String> = s.active().unwrap().history.redo_labels().map(str::to_string).collect();
|
||||
assert_eq!(redo.len(), 2);
|
||||
s.active_mut().unwrap().history.max_states = 2;
|
||||
s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap();
|
||||
assert!(s.rollback());
|
||||
assert_eq!(entries(&s), before);
|
||||
assert_eq!(s.active().unwrap().history.redo_labels().map(str::to_string).collect::<Vec<_>>(), redo);
|
||||
assert!(!red(&s, id), "back on the New Layer state, not on the inverted fill");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert!(red(&s, id), "the red fill is still redoable");
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ fn select_state(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.active_job().is_some() {
|
||||
return Err(EngineError::Other("the history can't move while a command is still running on this document".into()));
|
||||
}
|
||||
let keep_visibility = !s.visibility_undoable();
|
||||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
if target == past {
|
||||
return Ok(json!({ "state": target, "changed": false }));
|
||||
@@ -44,8 +45,19 @@ fn select_state(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut layers = None;
|
||||
let mut at = past;
|
||||
while at != target {
|
||||
let step = if target < at { st.history.undo(doc.clone()) } else { st.history.redo(doc.clone()) };
|
||||
let Some((d, l)) = step else { break };
|
||||
// History Options: visibility changes that are not history states stay as they are,
|
||||
// step by step as Undo / Redo keep them, except on the layers whose visibility the step
|
||||
// passed over changed.
|
||||
let undoing = target < at;
|
||||
let mut changed = if undoing { st.history.current_visibility().to_vec() } else { Vec::new() };
|
||||
let step = if undoing { st.history.undo(doc.clone()) } else { st.history.redo(doc.clone()) };
|
||||
let Some((mut d, l)) = step else { break };
|
||||
if keep_visibility {
|
||||
if !undoing {
|
||||
changed = st.history.current_visibility().to_vec();
|
||||
}
|
||||
crate::carry_visibility(&doc, &mut d, &changed);
|
||||
}
|
||||
doc = d;
|
||||
layers = Some(l);
|
||||
at = if target < at { at - 1 } else { at + 1 };
|
||||
|
||||
@@ -432,7 +432,7 @@ fn stack_modes_render_from_the_nested_layers() {
|
||||
surf.write_region(Rect::new(3, 3, 4, 4), &photocraft_raster::from_rgba(&fmt, [v, v, v, 1.0]));
|
||||
kids.push(l);
|
||||
}
|
||||
let g = Layer::new("stack", LayerContent::Group(photocraft_doc::Group { children: kids, expanded: true, artboard: None }));
|
||||
let g = Layer::new("stack", LayerContent::Group(photocraft_doc::Group { children: kids, expanded: true, artboard: None, frame_stroke: None }));
|
||||
let smart = crate::smart_cmds::layer_to_smart(doc, &g)?;
|
||||
let id = smart.id;
|
||||
doc.layers = vec![smart];
|
||||
|
||||
@@ -278,7 +278,7 @@ pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Resul
|
||||
/// Show/hide every selected layer in one step.
|
||||
pub fn set_visible_selected(s: &mut Session, visible: bool) -> Result<Value> {
|
||||
let sel = selected(s);
|
||||
s.edit(if visible { "Show Layers" } else { "Hide Layers" }, |doc, _| {
|
||||
s.edit_visibility(if visible { "Show Layers" } else { "Hide Layers" }, |doc, _| {
|
||||
for id in &sel {
|
||||
doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.visible = visible;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ fn check_packet(raw: &str) -> Result<()> {
|
||||
if raw.contains('\0') {
|
||||
return Err(bad("`rawData` can't contain NUL characters"));
|
||||
}
|
||||
let xml = roxmltree::Document::parse(raw).map_err(|e| bad(format!("`rawData` isn't well-formed XML: {e}")))?;
|
||||
let xml = photocraft_io::xml::parse(raw, roxmltree::ParsingOptions::default()).map_err(|e| bad(format!("`rawData` isn't well-formed XML: {e}")))?;
|
||||
let root = xml.root_element().tag_name();
|
||||
let xmp = matches!((root.namespace(), root.name()), (Some("adobe:ns:meta/"), "xmpmeta") | (Some("http://www.w3.org/1999/02/22-rdf-syntax-ns#"), "RDF"));
|
||||
if !xmp {
|
||||
|
||||
@@ -61,6 +61,27 @@ fn sets_reads_and_removes_as_one_undo_step_each() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `rawData` packet nested 20 000 deep overflowed the XML parser's recursion (an abort nothing
|
||||
/// can catch, from one command param); it is a param error now. The small stack makes a
|
||||
/// regression abort instead of passing.
|
||||
#[test]
|
||||
fn deeply_nested_packets_are_refused_not_a_stack_overflow() {
|
||||
let (s, a, _) = fixture(8);
|
||||
let raw = format!("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">{}{}</x:xmpmeta>", "<a>".repeat(20_000), "</a>".repeat(20_000));
|
||||
let (s, e) = std::thread::Builder::new()
|
||||
.stack_size(1024 * 1024)
|
||||
.spawn(move || {
|
||||
let mut s = s;
|
||||
let e = s.execute(ID, json!({"rawData": raw})).err();
|
||||
(s, e)
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
assert!(matches!(&e, Some(EngineError::BadParams { msg, .. }) if msg.contains("nested")), "{e:?}");
|
||||
assert_eq!(xmp(&s, a), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicating_a_layer_copies_its_packet() {
|
||||
let (mut s, a, _) = fixture(8);
|
||||
|
||||
@@ -71,6 +71,176 @@ pub struct Spooler {
|
||||
pub run: SpoolFn,
|
||||
}
|
||||
|
||||
/// A stand-in for the system's printers, installed on `Session::printer_source` (tests):
|
||||
/// `file.printers`, `file.print.printers` and the Print dialog list these and do not ask the spooler.
|
||||
/// `file.print.printers` and the dialog's driver Printer menu list them on Windows only, where the
|
||||
/// GDI route is; elsewhere they answer as that route does there. In the browser `file.printers`
|
||||
/// lists none of them, as it lists no printers there without a stand-in.
|
||||
pub type PrinterSource = std::sync::Arc<dyn Fn() -> Vec<craft_print::Printer> + Send + Sync>;
|
||||
|
||||
/// Held while a thread of this process lists printers or calls into a printer driver.
|
||||
static SPOOLER: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Runs `f`, which lists printers or calls into a printer driver, while no other thread of this
|
||||
/// process does either. On Windows, two threads that open printers' PrintTicket providers for the
|
||||
/// first time at once can crash the process inside the spooler client (`winspool.drv`). A panic in
|
||||
/// an earlier caller does not block later ones. A call made inside `f` on the same thread runs at
|
||||
/// once.
|
||||
pub fn with_spooler<T>(f: impl FnOnce() -> T) -> T {
|
||||
if HOLDING.get() {
|
||||
return f();
|
||||
}
|
||||
let _held = SPOOLER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
holding(f)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Whether this thread holds `SPOOLER`.
|
||||
static HOLDING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Runs `f` with `HOLDING` set; called with `SPOOLER` held.
|
||||
fn holding<T>(f: impl FnOnce() -> T) -> T {
|
||||
struct Release;
|
||||
impl Drop for Release {
|
||||
fn drop(&mut self) {
|
||||
HOLDING.set(false);
|
||||
}
|
||||
}
|
||||
HOLDING.set(true);
|
||||
let _release = Release;
|
||||
f()
|
||||
}
|
||||
|
||||
/// Counts the times this process asked the system for its printers.
|
||||
static SYSTEM_LISTINGS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
/// How many times this process has asked the system for its printers. A session with a
|
||||
/// `printer_source` does not ask.
|
||||
pub fn system_printer_listings() -> usize {
|
||||
SYSTEM_LISTINGS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Runs `f` as `with_spooler` does, or returns `None` at once while another thread of this
|
||||
/// process holds the lock (the UI thread, which must not wait for a print job or a driver).
|
||||
pub fn try_with_spooler<T>(f: impl FnOnce() -> T) -> Option<T> {
|
||||
if HOLDING.get() {
|
||||
return Some(f());
|
||||
}
|
||||
let _held = match SPOOLER.try_lock() {
|
||||
Ok(held) => held,
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(),
|
||||
Err(std::sync::TryLockError::WouldBlock) => return None,
|
||||
};
|
||||
Some(holding(f))
|
||||
}
|
||||
|
||||
/// What the Print dialog shows when `try_with_spooler` finds the lock held.
|
||||
pub const SPOOLER_BUSY: &str = "the printer driver is busy with another print; try again when it finishes";
|
||||
|
||||
/// The GDI route's printers, or why they could not be listed.
|
||||
pub type RgbPrinters = std::result::Result<Vec<craft_print::rgb::Printer>, craft_print::rgb::PrintError>;
|
||||
|
||||
/// A list kept with the session's `printer_source` it came from (`None` for the system's printers).
|
||||
type Kept<T> = std::sync::Mutex<Option<(Option<PrinterSource>, T)>>;
|
||||
|
||||
/// The last answers `system_printer_list` and `rgb_printers` got in this process.
|
||||
static LAST_PRINTERS: Kept<Vec<craft_print::Printer>> = std::sync::Mutex::new(None);
|
||||
static LAST_RGB_PRINTERS: Kept<RgbPrinters> = std::sync::Mutex::new(None);
|
||||
|
||||
/// The kept list when it came from `source`; a list from other printers is not returned.
|
||||
fn last<T: Clone>(kept: &Kept<T>, source: Option<&PrinterSource>) -> Option<T> {
|
||||
let kept = kept.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (from, list) = kept.as_ref()?;
|
||||
let same = match (from, source) {
|
||||
(None, None) => true,
|
||||
(Some(from), Some(source)) => std::sync::Arc::ptr_eq(from, source),
|
||||
_ => false,
|
||||
};
|
||||
same.then(|| list.clone())
|
||||
}
|
||||
|
||||
fn keep<T>(kept: &Kept<T>, source: Option<&PrinterSource>, value: T) {
|
||||
*kept.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some((source.cloned(), value));
|
||||
}
|
||||
|
||||
/// Lists the printers; called with the spooler lock held.
|
||||
fn list_system_printers(s: &Session) -> Vec<craft_print::Printer> {
|
||||
let list = match stand_in_printers(s.printer_source.as_ref(), cfg!(target_arch = "wasm32")) {
|
||||
Some(source) => source(),
|
||||
None => {
|
||||
SYSTEM_LISTINGS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
craft_print::printers()
|
||||
}
|
||||
};
|
||||
keep(&LAST_PRINTERS, s.printer_source.as_ref(), list.clone());
|
||||
list
|
||||
}
|
||||
|
||||
/// `file.printers`' stand-in: the session's outside the browser, and `None` in the browser, where
|
||||
/// `file.printers` lists no printers.
|
||||
fn stand_in_printers(source: Option<&PrinterSource>, browser: bool) -> Option<&PrinterSource> {
|
||||
source.filter(|_| !browser)
|
||||
}
|
||||
|
||||
/// Lists the GDI route's printers; called with the spooler lock held.
|
||||
fn list_rgb_printers(s: &Session) -> RgbPrinters {
|
||||
let list = match &s.printer_source {
|
||||
Some(source) => stand_in_rgb_printers(source, cfg!(windows)),
|
||||
None => {
|
||||
SYSTEM_LISTINGS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
craft_print::rgb::printers()
|
||||
}
|
||||
};
|
||||
keep(&LAST_RGB_PRINTERS, s.printer_source.as_ref(), list.clone());
|
||||
list
|
||||
}
|
||||
|
||||
/// The GDI route's answer from a stand-in: its printers on Windows, and `Unsupported` elsewhere,
|
||||
/// as `craft_print::rgb::printers` answers there.
|
||||
fn stand_in_rgb_printers(source: &PrinterSource, windows: bool) -> RgbPrinters {
|
||||
if !windows {
|
||||
return Err(craft_print::rgb::PrintError::Unsupported);
|
||||
}
|
||||
Ok(source().into_iter().map(|p| craft_print::rgb::Printer { name: p.name, default: p.default }).collect())
|
||||
}
|
||||
|
||||
/// The printers `craft-print` spools to, or the session's stand-in for them.
|
||||
fn system_printer_list(s: &Session) -> Vec<craft_print::Printer> {
|
||||
with_spooler(|| list_system_printers(s))
|
||||
}
|
||||
|
||||
/// The printers the GDI route prints to (`craft_print::rgb`), or on Windows the session's stand-in
|
||||
/// for them.
|
||||
pub fn rgb_printers(s: &Session) -> RgbPrinters {
|
||||
with_spooler(|| list_rgb_printers(s))
|
||||
}
|
||||
|
||||
/// Lists printers when the spooler is available. While it is busy, returns the cached list for
|
||||
/// the same source, or `Unsupported` if that source has no cached list.
|
||||
pub fn rgb_printers_without_waiting(s: &Session) -> RgbPrinters {
|
||||
try_with_spooler(|| list_rgb_printers(s))
|
||||
.or_else(|| last(&LAST_RGB_PRINTERS, s.printer_source.as_ref()))
|
||||
.unwrap_or(Err(craft_print::rgb::PrintError::Unsupported))
|
||||
}
|
||||
|
||||
/// Lists printers for the Print dialog. While the system spooler is busy, returns the cached
|
||||
/// list for the same source, or an empty list if that source has no cached list.
|
||||
pub fn printers_without_waiting(s: &mut Session) -> Value {
|
||||
if stand_in_printers(s.printer_source.as_ref(), cfg!(target_arch = "wasm32")).is_none() && system_spooler().is_none() {
|
||||
return system_printers(s, &json!({})).unwrap_or_default();
|
||||
}
|
||||
let list = try_with_spooler(|| list_system_printers(s)).or_else(|| last(&LAST_PRINTERS, s.printer_source.as_ref())).unwrap_or_default();
|
||||
printers_answer(list)
|
||||
}
|
||||
|
||||
/// `file.printers`' answer for a list from `craft-print`.
|
||||
fn printers_answer(list: Vec<craft_print::Printer>) -> Value {
|
||||
let default = list.iter().find(|p| p.default).map(|p| p.name.clone());
|
||||
let printers: Vec<Value> = list.into_iter().map(|p| json!({"name": p.name, "default": p.default})).collect();
|
||||
json!({"server": Value::Null, "default": default, "printers": printers})
|
||||
}
|
||||
|
||||
/// Why the IPP route could not spool: no route at all (the app's spooler may have one), or a
|
||||
/// failure on a route that exists (a wrong printer name, a rejected job).
|
||||
enum SpoolError {
|
||||
@@ -91,7 +261,7 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -
|
||||
}
|
||||
if let Some(name) = system_spooler() {
|
||||
require_windows_pdf()?;
|
||||
return craft_print::submit(&job.pdf, lp).map(|m| (m, name.to_string())).map_err(other);
|
||||
return with_spooler(|| craft_print::submit(&job.pdf, lp)).map(|m| (m, name.to_string())).map_err(other);
|
||||
}
|
||||
}
|
||||
match cups_spool(job) {
|
||||
@@ -190,13 +360,10 @@ fn cups_spool(job: &PrintJob) -> std::result::Result<String, SpoolError> {
|
||||
/// `file.printers`: the printers the system's (or `printServer`'s) print server knows, with the default. From
|
||||
/// `craft-print` (`lpstat`, the Windows spooler) where that is the route; over IPP for a named
|
||||
/// server and where there is no `lp` (a sandbox).
|
||||
fn system_printers(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn system_printers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let named = p.get("printServer").and_then(Value::as_str).filter(|v| !v.is_empty());
|
||||
if named.is_none() && system_spooler().is_some() {
|
||||
let list = craft_print::printers();
|
||||
let default = list.iter().find(|p| p.default).map(|p| p.name.clone());
|
||||
let printers: Vec<Value> = list.into_iter().map(|p| json!({"name": p.name, "default": p.default})).collect();
|
||||
return Ok(json!({"server": Value::Null, "default": default, "printers": printers}));
|
||||
if named.is_none() && (stand_in_printers(s.printer_source.as_ref(), cfg!(target_arch = "wasm32")).is_some() || system_spooler().is_some()) {
|
||||
return Ok(printers_answer(system_printer_list(s)));
|
||||
}
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
|
||||
{
|
||||
@@ -736,7 +903,8 @@ fn with_driver_printable(p: &Value) -> Value {
|
||||
|
||||
fn driver_printable(p: &Value) -> Option<Value> {
|
||||
let printer = printer_name(p).ok()?;
|
||||
let pg = craft_print::rgb::page(&printer, printer_settings(p).ok()?.as_ref()).ok()?;
|
||||
let settings = printer_settings(p).ok()?;
|
||||
let pg = with_spooler(|| craft_print::rgb::page(&printer, settings.as_ref())).ok()?;
|
||||
// Only for the paper the driver is set to: a print laid out on another paper keeps the
|
||||
// fixed margin (and `print_windows` refuses it), as before.
|
||||
let (pw, ph) = paper(p, "file.print").ok()?;
|
||||
@@ -905,7 +1073,7 @@ fn printer_name(p: &Value) -> Result<String> {
|
||||
if let Some(name) = p.get("printer").and_then(Value::as_str).filter(|v| !v.trim().is_empty()) {
|
||||
return Ok(name.to_string());
|
||||
}
|
||||
craft_print::rgb::default_printer().map_err(other)?.ok_or_else(|| other("no printer chosen and no default printer is set"))
|
||||
with_spooler(craft_print::rgb::default_printer).map_err(other)?.ok_or_else(|| other("no printer chosen and no default printer is set"))
|
||||
}
|
||||
|
||||
/// The driver settings chosen with Print Settings… (`printerSettings`, hex), when any.
|
||||
@@ -958,16 +1126,16 @@ fn settings_for_layout(
|
||||
settings: Option<craft_print::rgb::DevMode>,
|
||||
paper: (f64, f64),
|
||||
) -> Result<(Option<craft_print::rgb::DevMode>, craft_print::rgb::Page)> {
|
||||
let page = craft_print::rgb::page(printer, settings.as_ref()).map_err(other)?;
|
||||
let page = with_spooler(|| craft_print::rgb::page(printer, settings.as_ref())).map_err(other)?;
|
||||
if !turned_paper(page.paper, paper) {
|
||||
return Ok((settings, page));
|
||||
}
|
||||
let base = match settings {
|
||||
Some(s) => s,
|
||||
None => craft_print::rgb::default_settings(printer).map_err(other)?,
|
||||
None => with_spooler(|| craft_print::rgb::default_settings(printer)).map_err(other)?,
|
||||
};
|
||||
let turned = turned_settings(&base, paper.0 > paper.1)?;
|
||||
let page = craft_print::rgb::page(printer, Some(&turned)).map_err(other)?;
|
||||
let page = with_spooler(|| craft_print::rgb::page(printer, Some(&turned))).map_err(other)?;
|
||||
Ok((Some(turned), page))
|
||||
}
|
||||
|
||||
@@ -1027,14 +1195,14 @@ fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: u32) -> Resul
|
||||
color_policy,
|
||||
primitives: &primitives,
|
||||
};
|
||||
let done = craft_print::rgb::print(&job, &image, rect).map_err(other)?;
|
||||
let done = with_spooler(|| craft_print::rgb::print(&job, &image, rect)).map_err(other)?;
|
||||
Ok(
|
||||
json!({"printer": printer, "dpi": [done.dpi.0, done.dpi.1], "deviceRect": [done.device_rect.0, done.device_rect.1, done.device_rect.2, done.device_rect.3]}),
|
||||
)
|
||||
}
|
||||
|
||||
fn printers(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
match craft_print::rgb::printers() {
|
||||
fn printers(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
match rgb_printers(s) {
|
||||
Ok(list) => Ok(json!({"supported": true, "printers": list.iter().map(|p| json!({"name": p.name, "default": p.default})).collect::<Vec<_>>()})),
|
||||
Err(craft_print::rgb::PrintError::Unsupported) => Ok(json!({"supported": false, "printers": []})),
|
||||
Err(e) => Err(other(e)),
|
||||
@@ -1056,11 +1224,11 @@ fn printer_page(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(landscape) = landscape {
|
||||
let base = match settings {
|
||||
Some(s) => s,
|
||||
None => craft_print::rgb::default_settings(&printer).map_err(other)?,
|
||||
None => with_spooler(|| craft_print::rgb::default_settings(&printer)).map_err(other)?,
|
||||
};
|
||||
settings = Some(turned_settings(&base, landscape)?);
|
||||
}
|
||||
let pg = craft_print::rgb::page(&printer, settings.as_ref()).map_err(other)?;
|
||||
let pg = with_spooler(|| craft_print::rgb::page(&printer, settings.as_ref())).map_err(other)?;
|
||||
let mut out = json!({"printer": printer, "paper": [pg.paper.0, pg.paper.1], "printable": [pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3], "dpi": [pg.dpi.0, pg.dpi.1]});
|
||||
// The turned settings, to pass on as `printerSettings` (the Print dialog's Orientation does).
|
||||
if let (Some(_), Some(s), Some(o)) = (landscape, settings, out.as_object_mut()) {
|
||||
|
||||
@@ -258,17 +258,50 @@ fn native_rgb_route_preserves_pdf_only_color_and_page_content() {
|
||||
assert!(native_rgb_compatible(&page_of(1, vec![0; 2], false), "noColorManagement"));
|
||||
}
|
||||
|
||||
/// Two printers, the second the default, in place of the system spooler's.
|
||||
fn test_printers() -> PrinterSource {
|
||||
std::sync::Arc::new(|| {
|
||||
vec![
|
||||
craft_print::Printer { name: "Studio".into(), default: false, two_sided: None, description: String::new() },
|
||||
craft_print::Printer { name: "Office".into(), default: true, two_sided: Some(true), description: String::new() },
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printer_commands_answer_without_a_document_and_reject_bad_settings() {
|
||||
let mut s = Session::new();
|
||||
s.printer_source = Some(test_printers());
|
||||
let r = s.execute("file.print.printers", json!({})).unwrap();
|
||||
assert_eq!(r["supported"], cfg!(windows));
|
||||
assert!(r["printers"].is_array());
|
||||
let listed = if cfg!(windows) { json!([{"name": "Studio", "default": false}, {"name": "Office", "default": true}]) } else { json!([]) };
|
||||
assert_eq!(r["printers"], listed);
|
||||
assert!(s.execute("file.print.page", json!({"printer": "Anything", "printerSettings": "zz"})).is_err());
|
||||
let mut s = session("rgb", 8);
|
||||
assert!(s.execute("file.print", json!({"printer": "Anything", "printerSettings": "0102", "send": true})).is_err());
|
||||
}
|
||||
|
||||
/// The GDI route lists a stand-in's printers only on Windows. Elsewhere it answers `Unsupported`
|
||||
/// as the real route does, so the Print dialog shows the system's printers and paper controls.
|
||||
#[test]
|
||||
fn stand_in_printers_reach_the_gdi_route_on_windows_only() {
|
||||
let source = test_printers();
|
||||
assert_eq!(stand_in_rgb_printers(&source, false), Err(craft_print::rgb::PrintError::Unsupported));
|
||||
let listed = stand_in_rgb_printers(&source, true).unwrap();
|
||||
assert_eq!(listed.iter().map(|p| (p.name.as_str(), p.default)).collect::<Vec<_>>(), [("Studio", false), ("Office", true)]);
|
||||
}
|
||||
|
||||
/// `file.printers` lists a stand-in's printers everywhere but the browser. In the browser it lists
|
||||
/// none, as the real command does there.
|
||||
#[test]
|
||||
fn stand_in_printers_reach_file_printers_outside_the_browser_only() {
|
||||
let source = test_printers();
|
||||
assert!(stand_in_printers(Some(&source), true).is_none());
|
||||
let listed = stand_in_printers(Some(&source), false).map(|source| source());
|
||||
assert_eq!(listed.unwrap_or_default().iter().map(|p| (p.name.as_str(), p.default)).collect::<Vec<_>>(), [("Studio", false), ("Office", true)]);
|
||||
assert!(stand_in_printers(None, false).is_none());
|
||||
}
|
||||
|
||||
/// Synthetic driver settings: a DEVMODEW of `size` bytes plus `extra` private driver bytes, every
|
||||
/// byte a pattern so a stray write shows, with `dmFields` saying only the paper size is set.
|
||||
fn devmode(size: u16, extra: u16) -> Vec<u8> {
|
||||
@@ -676,13 +709,189 @@ fn disabled_printing_never_spools() {
|
||||
#[test]
|
||||
fn printers_lists_the_spooler_destinations_without_a_document() {
|
||||
let mut s = Session::new();
|
||||
s.printer_source = Some(test_printers());
|
||||
let r = s.execute("file.printers", json!({})).unwrap();
|
||||
let list = r["printers"].as_array().unwrap();
|
||||
assert!(list.iter().all(|p| p["name"].is_string() && p["default"].is_boolean()));
|
||||
match r["default"].as_str() {
|
||||
Some(d) => assert!(list.iter().any(|p| p["name"] == d && p["default"] == true)),
|
||||
None => assert!(list.iter().all(|p| p["default"] == false)),
|
||||
assert_eq!(r["printers"], json!([{"name": "Studio", "default": false}, {"name": "Office", "default": true}]));
|
||||
assert_eq!(r["default"], "Office");
|
||||
s.printer_source = Some(std::sync::Arc::new(Vec::new));
|
||||
let r = s.execute("file.printers", json!({})).unwrap();
|
||||
assert_eq!((r["printers"].clone(), r["default"].clone()), (json!([]), Value::Null));
|
||||
}
|
||||
|
||||
/// Threads that list printers at the same time enter the spooler one at a time: the stand-in
|
||||
/// printer list records how many threads were inside it at once.
|
||||
#[test]
|
||||
fn printer_listings_enter_the_spooler_one_thread_at_a_time() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Barrier};
|
||||
const THREADS: usize = 8;
|
||||
let inside = Arc::new(AtomicUsize::new(0));
|
||||
let most = Arc::new(AtomicUsize::new(0));
|
||||
let start = Arc::new(Barrier::new(THREADS));
|
||||
let threads: Vec<_> = (0..THREADS)
|
||||
.map(|i| {
|
||||
let (inside, most, start) = (Arc::clone(&inside), Arc::clone(&most), Arc::clone(&start));
|
||||
std::thread::spawn(move || {
|
||||
let mut s = Session::new();
|
||||
s.printer_source = Some(Arc::new(move || {
|
||||
let now = inside.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
most.fetch_max(now, Ordering::SeqCst);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
inside.fetch_sub(1, Ordering::SeqCst);
|
||||
vec![craft_print::Printer { name: "Office".into(), default: true, two_sided: None, description: String::new() }]
|
||||
}));
|
||||
start.wait();
|
||||
for _ in 0..3 {
|
||||
// `file.print.printers` lists the stand-in on Windows only.
|
||||
let id = if i % 2 == 0 || !cfg!(windows) { "file.printers" } else { "file.print.printers" };
|
||||
assert_eq!(s.execute(id, json!({})).unwrap()["printers"][0]["name"], "Office");
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for t in threads {
|
||||
t.join().unwrap();
|
||||
}
|
||||
assert_eq!(most.load(Ordering::SeqCst), 1, "two threads were in the printer listing at once");
|
||||
}
|
||||
|
||||
/// A caller that panics inside the spooler leaves it usable for later callers.
|
||||
#[test]
|
||||
fn a_panic_inside_the_spooler_does_not_block_later_callers() {
|
||||
let panicked = std::thread::spawn(|| with_spooler::<()>(|| panic!("a driver call panicked"))).join();
|
||||
assert!(panicked.is_err());
|
||||
assert_eq!(with_spooler(|| 7), 7);
|
||||
let mut s = Session::new();
|
||||
s.printer_source = Some(test_printers());
|
||||
assert_eq!(s.execute("file.printers", json!({})).unwrap()["default"], "Office");
|
||||
}
|
||||
|
||||
/// A driver call made while this thread already holds the spooler (a command run from the Print
|
||||
/// dialog's `try_with_spooler`) runs at once.
|
||||
#[test]
|
||||
fn a_thread_holding_the_spooler_does_not_wait_for_itself() {
|
||||
let (done, finished) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut s = Session::new();
|
||||
s.printer_source = Some(test_printers());
|
||||
// Inside one outer hold, so no other test takes the lock between the calls.
|
||||
let answers = with_spooler(|| {
|
||||
let nested = with_spooler(|| with_spooler(|| 7));
|
||||
let tried = try_with_spooler(|| with_spooler(|| try_with_spooler(|| 8)));
|
||||
let listed = try_with_spooler(|| s.execute("file.printers", json!({})).unwrap()["default"].clone());
|
||||
(nested, tried, listed)
|
||||
});
|
||||
done.send(answers).unwrap();
|
||||
});
|
||||
// Bounded, so a thread that waits for itself fails the test rather than hanging it.
|
||||
let answers = finished.recv_timeout(std::time::Duration::from_secs(30)).expect("a thread holding the spooler waited for itself");
|
||||
assert_eq!(answers, (7, Some(Some(8)), Some(json!("Office"))));
|
||||
}
|
||||
|
||||
/// While another thread is in the spooler (a print job being handed over, Print Settings…), the
|
||||
/// Print dialog's listings return at once with the last lists this process got.
|
||||
#[test]
|
||||
fn listings_without_waiting_return_the_last_lists_while_the_spooler_is_busy() {
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, mpsc};
|
||||
let asked = Arc::new(AtomicUsize::new(0));
|
||||
let counted = Arc::clone(&asked);
|
||||
let mut s = Session::new();
|
||||
s.printer_source = Some(Arc::new(move || {
|
||||
counted.fetch_add(1, Ordering::SeqCst);
|
||||
vec![craft_print::Printer { name: "Office".into(), default: true, two_sided: None, description: String::new() }]
|
||||
}));
|
||||
let (held_tx, held) = mpsc::channel();
|
||||
let (release, released_rx) = mpsc::channel::<()>();
|
||||
let released = Arc::new(AtomicBool::new(false));
|
||||
let holder = {
|
||||
let released = Arc::clone(&released);
|
||||
let mut lister = Session::new();
|
||||
lister.printer_source = s.printer_source.clone();
|
||||
std::thread::spawn(move || {
|
||||
with_spooler(|| {
|
||||
// Lists this stand-in's printers once with the lock held, so another test's listing
|
||||
// cannot replace the last lists before the listings below.
|
||||
held_tx.send((list_system_printers(&lister), list_rgb_printers(&lister))).unwrap();
|
||||
// Bounded, so a listing that waits fails the test rather than hanging it.
|
||||
let _ = released_rx.recv_timeout(std::time::Duration::from_secs(5));
|
||||
released.store(true, Ordering::SeqCst);
|
||||
});
|
||||
})
|
||||
};
|
||||
let (kept, kept_rgb) = held.recv().unwrap();
|
||||
let before = asked.load(Ordering::SeqCst);
|
||||
let listed = printers_without_waiting(&mut s);
|
||||
let listed_rgb = rgb_printers_without_waiting(&s);
|
||||
assert!(!released.load(Ordering::SeqCst), "the listing waited for the spooler");
|
||||
release.send(()).unwrap();
|
||||
holder.join().unwrap();
|
||||
assert_eq!(asked.load(Ordering::SeqCst), before, "a printer list was asked for while the spooler was busy");
|
||||
assert_eq!(listed, printers_answer(kept));
|
||||
assert_eq!(listed_rgb, kept_rgb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listings_without_waiting_skip_the_lists_of_another_stand_in() {
|
||||
uncached_listings_do_not_wait(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listings_without_waiting_return_empty_when_no_list_was_cached() {
|
||||
uncached_listings_do_not_wait(false);
|
||||
}
|
||||
|
||||
fn uncached_listings_do_not_wait(cache_other: bool) {
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, mpsc};
|
||||
let asked = Arc::new(AtomicUsize::new(0));
|
||||
let counted = Arc::clone(&asked);
|
||||
let mut s = Session::new();
|
||||
s.printer_source = Some(Arc::new(move || {
|
||||
counted.fetch_add(1, Ordering::SeqCst);
|
||||
vec![craft_print::Printer { name: "Proofing".into(), default: true, two_sided: None, description: String::new() }]
|
||||
}));
|
||||
let (held_tx, held) = mpsc::channel();
|
||||
let (release, released_rx) = mpsc::channel();
|
||||
let released = Arc::new(AtomicBool::new(false));
|
||||
let done = Arc::clone(&released);
|
||||
let holder = std::thread::spawn(move || {
|
||||
with_spooler(|| {
|
||||
if cache_other {
|
||||
let mut other = Session::new();
|
||||
other.printer_source = Some(test_printers());
|
||||
list_system_printers(&other);
|
||||
let _ = list_rgb_printers(&other);
|
||||
} else {
|
||||
*LAST_PRINTERS.lock().unwrap() = None;
|
||||
*LAST_RGB_PRINTERS.lock().unwrap() = None;
|
||||
}
|
||||
held_tx.send(()).unwrap();
|
||||
let _ = released_rx.recv_timeout(std::time::Duration::from_secs(5));
|
||||
done.store(true, Ordering::SeqCst);
|
||||
});
|
||||
});
|
||||
held.recv().unwrap();
|
||||
let listed = printers_without_waiting(&mut s);
|
||||
let listed_rgb = rgb_printers_without_waiting(&s);
|
||||
let waited = released.load(Ordering::SeqCst);
|
||||
let _ = release.send(());
|
||||
holder.join().unwrap();
|
||||
assert!(!waited, "an uncached listing waited for the spooler");
|
||||
assert_eq!(asked.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(listed, printers_answer(Vec::new()));
|
||||
assert_eq!(listed_rgb, Err(craft_print::rgb::PrintError::Unsupported));
|
||||
with_spooler(|| {
|
||||
let listed = printers_without_waiting(&mut s);
|
||||
let listed_rgb = rgb_printers_without_waiting(&s);
|
||||
assert_eq!(listed["printers"], json!([{"name": "Proofing", "default": true}]));
|
||||
let names = listed_rgb.map(|list| list.into_iter().map(|p| p.name).collect::<Vec<_>>());
|
||||
if cfg!(windows) {
|
||||
assert_eq!(names, Ok(vec!["Proofing".to_string()]));
|
||||
} else {
|
||||
assert_eq!(names, Err(craft_print::rgb::PrintError::Unsupported));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Spooling tests run in a child process: `disable_printing` is process-wide and a test above
|
||||
|
||||
@@ -161,7 +161,7 @@ fn unpacked(so: &Layer, mut layers: Vec<Layer>) -> Layer {
|
||||
g.id = LayerId::fresh();
|
||||
g.psd_id = None;
|
||||
g.blend = blend;
|
||||
g.content = LayerContent::Group(Group { children: layers, expanded: true, artboard: None });
|
||||
g.content = LayerContent::Group(Group { children: layers, expanded: true, artboard: None, frame_stroke: None });
|
||||
detach_psd(&mut g);
|
||||
g
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
|
||||
}
|
||||
|
||||
/// Generate ▸ Image Assets needs Preferences ▸ Plug-ins ▸ Generator enabled, as in
|
||||
/// Generate ▸ Image Assets needs Preferences ▸ Plug-ins ▸ Enable Generator, as in
|
||||
/// Photoshop (where "Enable Generator" is unchecked until turned on).
|
||||
fn generator_enabled(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
|
||||
@@ -637,7 +637,7 @@ fn image_assets_needs_the_generator_preference() {
|
||||
fn image_assets_are_generated_now_and_on_save() {
|
||||
let dir = tmp("assets");
|
||||
let mut s = session(8);
|
||||
// Preferences ▸ Plug-ins ▸ Generator enabled: the feature is opt-in.
|
||||
// Preferences ▸ Plug-ins ▸ Enable Generator: the feature is opt-in.
|
||||
s.execute("prefs.set", json!({"path": "plugIns.generatorEnabled", "value": true})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
s.execute("layer.renameLayer", json!({"layer": id.0, "name": "logo.png, 50% small/logo.gif, logo.jpg80%"})).unwrap();
|
||||
@@ -661,7 +661,10 @@ fn image_assets_are_generated_now_and_on_save() {
|
||||
let s2 = session(8);
|
||||
let doc = s2.active().unwrap().doc.clone();
|
||||
let mut d = (*doc).clone();
|
||||
d.layers.push(Layer::new("default 200% @2x", LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None })));
|
||||
d.layers.push(Layer::new(
|
||||
"default 200% @2x",
|
||||
LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None, frame_stroke: None }),
|
||||
));
|
||||
let lid = d.layers[1].id;
|
||||
d.layer_mut(lid).unwrap().name = "icon.png".into();
|
||||
let (files, errors) = generate_assets(&d, &dir);
|
||||
@@ -684,7 +687,7 @@ fn image_assets_keep_full_artboards() {
|
||||
logo.surface_mut().unwrap().fill_rect(Rect::new(12, 10, 20, 16), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0]));
|
||||
let board =
|
||||
photocraft_doc::Artboard { rect: Rect::new(4, 6, 44, 36), background: photocraft_doc::ArtboardBackground::Transparent, preset: String::new() };
|
||||
let group = photocraft_doc::Group { children: vec![logo], expanded: true, artboard: Some(board) };
|
||||
let group = photocraft_doc::Group { children: vec![logo], expanded: true, artboard: Some(board), frame_stroke: None };
|
||||
doc.layers = vec![Layer::new("board.png", LayerContent::Group(group))];
|
||||
Ok(())
|
||||
})
|
||||
@@ -722,8 +725,8 @@ fn image_assets_refuse_empty_artboards() {
|
||||
background: photocraft_doc::ArtboardBackground::Transparent,
|
||||
preset: String::new(),
|
||||
};
|
||||
let empty = photocraft_doc::Group { children: vec![], expanded: true, artboard: Some(board(0)) };
|
||||
let off = photocraft_doc::Group { children: vec![outside], expanded: true, artboard: Some(board(24)) };
|
||||
let empty = photocraft_doc::Group { children: vec![], expanded: true, artboard: Some(board(0)), frame_stroke: None };
|
||||
let off = photocraft_doc::Group { children: vec![outside], expanded: true, artboard: Some(board(24)), frame_stroke: None };
|
||||
doc.layers = vec![Layer::new("empty.png", LayerContent::Group(empty)), Layer::new("off.png", LayerContent::Group(off))];
|
||||
Ok(())
|
||||
})
|
||||
@@ -770,8 +773,10 @@ fn image_assets_never_write_one_file_twice() {
|
||||
let dir = tmp("asset-variant-collisions");
|
||||
d.layer_mut(top).unwrap().name = "icon.png".into();
|
||||
d.layer_mut(logo).unwrap().name = "Logo".into();
|
||||
d.layers
|
||||
.push(Layer::new("default 50% + 200% + 200% @2x", LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None })));
|
||||
d.layers.push(Layer::new(
|
||||
"default 50% + 200% + 200% @2x",
|
||||
LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None, frame_stroke: None }),
|
||||
));
|
||||
let (files, errors) = generate_assets(&d, &dir);
|
||||
let names: Vec<_> = files.iter().map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(names, ["icon.png", "icon@2x.png"]);
|
||||
@@ -799,7 +804,7 @@ fn image_assets_reject_traversal_from_names_and_defaults() {
|
||||
d.layer_mut(id).unwrap().name = "safe.png".into();
|
||||
d.layers.push(Layer::new(
|
||||
"default ../ + /outside/ + ..\\bad/ + C:\\drive/ + 50% nested/",
|
||||
LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None }),
|
||||
LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None, frame_stroke: None }),
|
||||
));
|
||||
let (files, errors) = generate_assets(&d, &dir);
|
||||
assert_eq!(
|
||||
|
||||
@@ -21,6 +21,8 @@ pub fn session(s: &Session) -> Value {
|
||||
})).collect::<Vec<_>>(),
|
||||
"foreground": s.tools.foreground,
|
||||
"background": s.tools.background,
|
||||
// History panel › History Options… (`history.options`).
|
||||
"historyOptions": crate::history_options_cmds::current(s),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ pub fn parse(text: &str) -> Result<KysSet, String> {
|
||||
return Err("not a keyboard shortcut set (.kys): larger than 4 MiB".into());
|
||||
}
|
||||
let text = text.trim_start_matches('\u{feff}');
|
||||
let doc = roxmltree::Document::parse(text).map_err(|e| format!("not a keyboard shortcut set (.kys): {e}"))?;
|
||||
let doc = photocraft_io::xml::parse(text, roxmltree::ParsingOptions::default()).map_err(|e| format!("not a keyboard shortcut set (.kys): {e}"))?;
|
||||
let root = doc.root_element();
|
||||
if root.tag_name().name() != "photoshop-keyboard-shortcuts" {
|
||||
return Err("not a keyboard shortcut set (.kys): no <photoshop-keyboard-shortcuts> root".into());
|
||||
@@ -342,6 +342,15 @@ mod tests {
|
||||
assert_eq!(p.alternate_keys.get("image.levels"), Some(&Vec::new()));
|
||||
}
|
||||
|
||||
/// A set nested 20 000 deep overflowed the XML parser's recursion (an abort nothing can
|
||||
/// catch); it is an error now. The small stack makes a regression abort instead of passing.
|
||||
#[test]
|
||||
fn deeply_nested_sets_are_an_error_not_a_stack_overflow() {
|
||||
let xml = format!("<photoshop-keyboard-shortcuts>{}{}</photoshop-keyboard-shortcuts>", "<a>".repeat(20_000), "</a>".repeat(20_000));
|
||||
let e = std::thread::Builder::new().stack_size(512 * 1024).spawn(move || parse(&xml).err()).unwrap().join().unwrap();
|
||||
assert!(e.as_deref().is_some_and(|m| m.contains("nested")), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_with_the_default_and_a_function_key_keeps_both() {
|
||||
let xml =
|
||||
|
||||
@@ -97,6 +97,7 @@ pub use cmd::gradient_fill_cmds;
|
||||
pub use cmd::group_view_cmds;
|
||||
pub use cmd::history_cmds;
|
||||
pub use cmd::history_menu_cmds;
|
||||
pub use cmd::history_options_cmds;
|
||||
pub use cmd::history_select_cmds;
|
||||
pub use cmd::history_source_cmds;
|
||||
pub use cmd::image_cmds;
|
||||
@@ -161,9 +162,9 @@ pub use cmd::web_cmds;
|
||||
use cmd::wia_cmds;
|
||||
pub use doc_state::{CopiedStyle, DocState, PRECHECKED, ToolState};
|
||||
pub use error::{EngineError, Result};
|
||||
pub(crate) use session::fix_selection;
|
||||
pub use session::move_item;
|
||||
use session::restore_target;
|
||||
pub(crate) use session::{carry_visibility, fix_selection, visibility_changes};
|
||||
|
||||
/// The pixels of a raster layer (typically one a command just created), as an error instead
|
||||
/// of a panic if the layer has none.
|
||||
@@ -242,6 +243,9 @@ pub struct Session {
|
||||
/// A further route for File › Print when neither `lp` nor a CUPS server over IPP is
|
||||
/// reachable (the XDG Print portal in a Flatpak); see `print_cmds::Spooler`.
|
||||
pub print_spooler: Option<print_cmds::Spooler>,
|
||||
/// The printers `file.printers`, `file.print.printers` and the Print dialog list in place of
|
||||
/// the system's (tests); see `print_cmds::PrinterSource`. `None` asks the spooler.
|
||||
pub printer_source: Option<print_cmds::PrinterSource>,
|
||||
/// Background jobs (see [`jobs`]).
|
||||
jobs: jobs::Jobs,
|
||||
/// Color Sampler points dropped by the last canvas-size change (crop, Canvas Size, Image Size,
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
//! OS config directory; the web shell: browser storage) and restore it with
|
||||
//! [`Session::load_prefs_json`].
|
||||
//!
|
||||
//! Engine-side values are applied by [`Session::apply_prefs`]: the history state limit (every
|
||||
//! open document), the effect-cache memory budget, and units for commands that report lengths.
|
||||
//! Engine-side values are applied by [`Session::apply_prefs`]: the history state limit and
|
||||
//! History Options › Allow Non-Linear History (every open document), the effect-cache memory
|
||||
//! budget, and units for commands that report lengths.
|
||||
//! Colour settings (`colorSettings`) are stored with the colour state
|
||||
//! ([`crate::color_cmds::ColorSettings`]) and persisted in the same document.
|
||||
|
||||
@@ -998,6 +999,24 @@ impl Default for Integrations {
|
||||
}
|
||||
}
|
||||
|
||||
/// The History panel's History Options… (not part of Edit › Preferences in Photoshop either).
|
||||
/// Photoshop's snapshot options come with #3634.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct HistoryOptions {
|
||||
/// Allow Non-Linear History: editing after selecting an earlier state keeps the later
|
||||
/// states (the new step is appended at the end), and deleting a state deletes only it.
|
||||
pub allow_non_linear_history: bool,
|
||||
/// Make Layer Visibility Changes Undoable: showing or hiding a layer is a history state.
|
||||
pub make_layer_visibility_changes_undoable: bool,
|
||||
}
|
||||
|
||||
impl Default for HistoryOptions {
|
||||
fn default() -> Self {
|
||||
Self { allow_non_linear_history: false, make_layer_visibility_changes_undoable: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit › Menus: hidden items and item colours, by command id.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -1083,6 +1102,8 @@ pub struct Preferences {
|
||||
pub enhanced_controls: EnhancedControls,
|
||||
pub raw_defaults: RawDefaults,
|
||||
pub integrations: Integrations,
|
||||
/// History panel › History Options… (`history.options`).
|
||||
pub history_options: HistoryOptions,
|
||||
/// Edit › Keyboard Shortcuts: command id → shortcut (`Cmd+Shift+N` notation); an empty
|
||||
/// string removes the default shortcut.
|
||||
pub shortcuts: BTreeMap<String, String>,
|
||||
@@ -1268,15 +1289,18 @@ impl Session {
|
||||
r
|
||||
}
|
||||
|
||||
/// Push engine-relevant preferences into live state: every document's history limit, the
|
||||
/// transform and layer-effect cache budgets and the text engine's missing-glyph protection.
|
||||
/// Push engine-relevant preferences into live state: every document's history limit and
|
||||
/// linearity, the transform and layer-effect cache budgets and the text engine's
|
||||
/// missing-glyph protection.
|
||||
pub fn apply_prefs(&mut self) {
|
||||
let n = self.prefs().performance.history_states.max(1) as usize;
|
||||
let bytes = self.prefs().performance.history_budget_bytes();
|
||||
let budget = self.prefs().performance.effect_cache_mb as usize;
|
||||
let non_linear = self.prefs().history_options.allow_non_linear_history;
|
||||
for st in &mut self.docs {
|
||||
st.history.max_states = n;
|
||||
st.history.max_bytes = bytes;
|
||||
st.history.non_linear = non_linear;
|
||||
st.trim_history();
|
||||
}
|
||||
photocraft_compose::set_effect_cache_budget(budget << 20);
|
||||
|
||||
@@ -8,4 +8,4 @@ mod history;
|
||||
mod layers;
|
||||
|
||||
pub use documents::move_item;
|
||||
pub(crate) use history::{fix_selection, restore_target};
|
||||
pub(crate) use history::{carry_visibility, fix_selection, restore_target, visibility_changes};
|
||||
|
||||
@@ -109,6 +109,7 @@ impl Session {
|
||||
let mut st = DocState::new(doc, path);
|
||||
st.history.max_states = self.prefs.get().performance.history_states.max(1) as usize;
|
||||
st.history.max_bytes = self.prefs.get().performance.history_budget_bytes();
|
||||
st.history.non_linear = self.prefs.get().history_options.allow_non_linear_history;
|
||||
self.docs.push(st);
|
||||
let i = self.docs.len() - 1;
|
||||
self.active = Some(i);
|
||||
|
||||
@@ -169,6 +169,25 @@ impl Session {
|
||||
|
||||
/// Apply an undoable edit to the active document.
|
||||
pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option<LayerId>) -> Result<R>) -> Result<R> {
|
||||
self.edit_with(label, true, f)
|
||||
}
|
||||
|
||||
/// Apply a change of layer visibility (and nothing else): a history state when History
|
||||
/// Options › Make Layer Visibility Changes Undoable is on (the default, as in Photoshop),
|
||||
/// otherwise the document changes without one, so the History panel and Undo skip it.
|
||||
pub fn edit_visibility<R>(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option<LayerId>) -> Result<R>) -> Result<R> {
|
||||
let record = self.visibility_undoable();
|
||||
self.edit_with(label, record, f)
|
||||
}
|
||||
|
||||
/// History Options › Make Layer Visibility Changes Undoable.
|
||||
pub fn visibility_undoable(&self) -> bool {
|
||||
self.prefs().history_options.make_layer_visibility_changes_undoable
|
||||
}
|
||||
|
||||
/// [`Self::edit`], recording a history state only when `record` is set. An unrecorded change
|
||||
/// still marks the document modified and leaves the redo states alone.
|
||||
pub(crate) fn edit_with<R>(&mut self, label: &str, record: bool, f: impl FnOnce(&mut Document, &mut Option<LayerId>) -> Result<R>) -> Result<R> {
|
||||
// Every mutation path (linked-file refresh, transform, place) re-renders smart objects
|
||||
// through this entry; the requesting session's authority must reach those renders.
|
||||
let _authorization = crate::jobs::render_authorization_scope(self.authorize);
|
||||
@@ -229,14 +248,20 @@ impl Session {
|
||||
let key = self.coalesce_request.clone();
|
||||
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
let layers = st.layer_target();
|
||||
if key.is_none() || st.coalesce != key || !st.history.can_undo() {
|
||||
if !record {
|
||||
// Nothing to coalesce with: the next recorded step starts a state of its own.
|
||||
st.coalesce = None;
|
||||
} else if key.is_none() || st.coalesce != key || !st.history.can_undo() {
|
||||
st.history.set_current_layers(prior);
|
||||
st.history.record(label, before, layers);
|
||||
st.history.record(label, before.clone(), layers);
|
||||
st.history.add_current_visibility(&visibility_changes(&before, &st.doc));
|
||||
st.trim_history();
|
||||
st.coalesce = key;
|
||||
} else {
|
||||
st.history.set_current_layers(layers);
|
||||
st.history.add_current_visibility(&visibility_changes(&before, &st.doc));
|
||||
st.coalesce = key;
|
||||
}
|
||||
st.coalesce = key;
|
||||
st.bump_revision(keep_clean);
|
||||
st.last_damage = damage;
|
||||
self.samplers_removed += samplers_removed;
|
||||
|
||||
@@ -7,8 +7,11 @@ impl Session {
|
||||
if self.active_job().is_some() {
|
||||
return false;
|
||||
}
|
||||
let keep_visibility = !self.visibility_undoable();
|
||||
let Some(st) = self.active_mut() else { return false };
|
||||
st.coalesce = None;
|
||||
// The step being undone: the layers whose visibility it changed get it back.
|
||||
let changed = st.history.current_visibility().to_vec();
|
||||
match st.history.undo(st.doc.clone()) {
|
||||
// Undo Clear History: the list comes back, the document is the same one. Not an
|
||||
// edit: keep a clean document clean (the revision still moves for the panels).
|
||||
@@ -27,6 +30,9 @@ impl Session {
|
||||
if d.name != st.doc.name {
|
||||
Arc::make_mut(&mut d).name.clone_from(&st.doc.name);
|
||||
}
|
||||
if keep_visibility {
|
||||
carry_visibility(&st.doc, &mut d, &changed);
|
||||
}
|
||||
// Pixels this step can have touched, so the canvas recomposites only that
|
||||
// (it recomposited everything before).
|
||||
let damage = layer_multi_cmds::step_damage(&d, &st.doc);
|
||||
@@ -45,6 +51,7 @@ impl Session {
|
||||
if self.active_job().is_some() {
|
||||
return false;
|
||||
}
|
||||
let keep_visibility = !self.visibility_undoable();
|
||||
let Some(st) = self.active_mut() else { return false };
|
||||
st.coalesce = None;
|
||||
match st.history.redo(st.doc.clone()) {
|
||||
@@ -52,6 +59,11 @@ impl Session {
|
||||
if d.name != st.doc.name {
|
||||
Arc::make_mut(&mut d).name.clone_from(&st.doc.name);
|
||||
}
|
||||
if keep_visibility {
|
||||
// The step being redone sets the visibility it changed again.
|
||||
let changed = st.history.current_visibility().to_vec();
|
||||
carry_visibility(&st.doc, &mut d, &changed);
|
||||
}
|
||||
let damage = layer_multi_cmds::step_damage(&st.doc, &d);
|
||||
let before = std::mem::replace(&mut st.doc, d);
|
||||
let keep_clean = st.keeps_clean(&before);
|
||||
@@ -65,6 +77,79 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Take back the step a compound command just recorded, after its later part failed: the
|
||||
/// document before the command is current again, nothing is left to redo, and in non-linear
|
||||
/// history the later states that step moved stay where they were (an undo would land on the
|
||||
/// last of them). Falls back to Undo plus forgetting the redo states when the history
|
||||
/// changed since that step. Returns whether a step was taken back.
|
||||
pub fn rollback(&mut self) -> bool {
|
||||
if self.active_job().is_some() {
|
||||
return false;
|
||||
}
|
||||
let keep_visibility = !self.visibility_undoable();
|
||||
let Some(st) = self.active_mut() else { return false };
|
||||
st.coalesce = None;
|
||||
// The step taken back (and the later states it moved, in non-linear history): the layers
|
||||
// whose visibility it changed get it back, the unrecorded changes stay, as for Undo.
|
||||
let changed = st.history.current_visibility().to_vec();
|
||||
let Some((mut d, layers)) = st.history.rollback() else {
|
||||
let undone = self.undo();
|
||||
if undone && let Some(st) = self.active_mut() {
|
||||
st.history.clear_redo();
|
||||
}
|
||||
return undone;
|
||||
};
|
||||
if d.name != st.doc.name {
|
||||
Arc::make_mut(&mut d).name.clone_from(&st.doc.name);
|
||||
}
|
||||
if keep_visibility {
|
||||
carry_visibility(&st.doc, &mut d, &changed);
|
||||
}
|
||||
let damage = layer_multi_cmds::step_damage(&d, &st.doc);
|
||||
let before = std::mem::replace(&mut st.doc, d);
|
||||
// Taking back a selection-only step keeps a clean document clean, as Undo does (#6865).
|
||||
let keep_clean = st.keeps_clean(&before);
|
||||
restore_target(st, layers);
|
||||
st.bump_revision(keep_clean);
|
||||
st.last_damage = damage;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// History Options › Make Layer Visibility Changes Undoable off: showing or hiding a layer is not
|
||||
/// a history state, so stepping through history must not bring the old visibility back either
|
||||
/// (history states hold whole documents). Gives every layer of `to` that `from` also has the
|
||||
/// visibility it has in `from`, except the `stepped` layers: a recorded step between the two
|
||||
/// states changed their visibility (Apply Layer Comp, a snapshot, a visibility change recorded
|
||||
/// while the option was on), so they keep the visibility `to` holds. Layers only one of them has
|
||||
/// keep theirs. Copies `to` only when something differs.
|
||||
pub(crate) fn carry_visibility(from: &Document, to: &mut Arc<Document>, stepped: &[LayerId]) {
|
||||
let visible: std::collections::HashMap<LayerId, bool> = from.walk().into_iter().map(|(_, _, l)| (l.id, l.visible)).collect();
|
||||
let changes: Vec<(LayerId, bool)> = to
|
||||
.walk()
|
||||
.into_iter()
|
||||
.filter(|(_, _, l)| !stepped.contains(&l.id))
|
||||
.filter_map(|(_, _, l)| visible.get(&l.id).filter(|v| **v != l.visible).map(|v| (l.id, *v)))
|
||||
.collect();
|
||||
if changes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let d = Arc::make_mut(to);
|
||||
for (id, v) in changes {
|
||||
if let Some(l) = d.layer_mut(id) {
|
||||
l.visible = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layers (in both documents) whose visibility differs between `before` and `after`: what a
|
||||
/// recorded step changed, for [`photocraft_ops::History::add_current_visibility`].
|
||||
pub(crate) fn visibility_changes(before: &Document, after: &Document) -> Vec<LayerId> {
|
||||
let visible: std::collections::HashMap<LayerId, bool> = before.walk().into_iter().map(|(_, _, l)| (l.id, l.visible)).collect();
|
||||
after.walk().into_iter().filter(|(_, _, l)| visible.get(&l.id).is_some_and(|v| *v != l.visible)).map(|(_, _, l)| l.id).collect()
|
||||
}
|
||||
|
||||
/// Undo / redo: target the layers the restored state targeted, as far as they still exist.
|
||||
pub(crate) fn restore_target(st: &mut DocState, layers: LayerTarget) {
|
||||
// Deselect Layers is a valid history target, not a missing layer to replace with the top
|
||||
|
||||
@@ -13,6 +13,8 @@ fn fresh() -> Session {
|
||||
// Belt and braces with the EXTERNAL skip below: nothing in this process may reach a printer.
|
||||
photocraft_engine::print_cmds::disable_printing();
|
||||
let mut s = Session::new();
|
||||
// `file.printers` and `file.print.printers` list no printers and do not ask the spooler.
|
||||
s.printer_source = Some(std::sync::Arc::new(Vec::new));
|
||||
s.execute("file.new", json!({"width": 24, "height": 16})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 1, "y": 1, "width": 6, "height": 5})).unwrap();
|
||||
|
||||
@@ -180,38 +180,52 @@ fn worker(rx: Receiver<Job>, bundle: PathBuf, sidecar: PathBuf, last: Arc<Mutex<
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a recovery sidecar atomically: a flushed temporary file renamed over the old one.
|
||||
///
|
||||
/// The replacing step is a plain rename on every platform, never `ReplaceFileW` (what
|
||||
/// [`crate::atomic::RealFs`] uses on Windows to keep a user file's ACL and named streams). That
|
||||
/// call first moves the old file to a backup name and only then moves the new one in, so for a
|
||||
/// moment `<key>.json` does not exist: a listing in between took the entry for a sidecar-less
|
||||
/// bundle and listed it twice, or not at all (#9034). A rename swaps the name in one step, and
|
||||
/// recovery data is private to the app, so there is no metadata to keep.
|
||||
fn write_sidecar(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
struct PrivateFs;
|
||||
impl crate::atomic::AtomicFs for PrivateFs {
|
||||
fn write_new(&self, tmp: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
struct SidecarFs;
|
||||
impl crate::atomic::AtomicFs for SidecarFs {
|
||||
// As before: Windows refuses a read-only sidecar up front, Unix replaces it.
|
||||
#[cfg(not(unix))]
|
||||
fn is_readonly(&self, path: &Path) -> bool {
|
||||
crate::atomic::RealFs.is_readonly(path)
|
||||
}
|
||||
fn write_new(&self, tmp: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(tmp)?;
|
||||
f.write_all(bytes)?;
|
||||
crate::atomic::sync_file(&f)
|
||||
}
|
||||
fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
crate::atomic::RealFs.rename(from, to)
|
||||
}
|
||||
fn remove(&self, path: &Path) -> std::io::Result<()> {
|
||||
crate::atomic::RealFs.remove(path)
|
||||
}
|
||||
fn sync_dir(&self, dir: &Path) -> std::io::Result<()> {
|
||||
crate::atomic::RealFs.sync_dir(dir)
|
||||
}
|
||||
fn copy_metadata(&self, from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
crate::atomic::RealFs.copy_metadata(from, to)
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
crate::atomic::RealFs.write_new(tmp, bytes)
|
||||
}
|
||||
}
|
||||
crate::atomic::atomic_write_with(&PrivateFs, crate::atomic::RenameRetry::platform(), path, bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
crate::store::write_atomic(path, bytes)
|
||||
fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
crate::atomic::RealFs.rename(from, to)
|
||||
}
|
||||
// `replace_existing` keeps the trait's default: this same one-step rename.
|
||||
fn remove(&self, path: &Path) -> std::io::Result<()> {
|
||||
crate::atomic::RealFs.remove(path)
|
||||
}
|
||||
fn sync_dir(&self, dir: &Path) -> std::io::Result<()> {
|
||||
crate::atomic::RealFs.sync_dir(dir)
|
||||
}
|
||||
fn copy_metadata(&self, from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
crate::atomic::RealFs.copy_metadata(from, to)
|
||||
}
|
||||
}
|
||||
crate::atomic::atomic_write_with(&SidecarFs, crate::atomic::RenameRetry::platform(), path, bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_sidecar_bounded(path: &Path, max: usize) -> Option<Vec<u8>> {
|
||||
|
||||
@@ -125,9 +125,12 @@ fn video_m(v: &photocraft_doc::VideoData, sink: &mut dyn Sink) -> crate::manifes
|
||||
fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM {
|
||||
let content = match &l.content {
|
||||
LayerContent::Raster(s) => ContentM::Raster { surface: surface_m(s, sink) },
|
||||
LayerContent::Group(g) => {
|
||||
ContentM::Group { children: g.children.iter().map(|c| layer_m(c, sink)).collect(), expanded: g.expanded, artboard: g.artboard.clone() }
|
||||
}
|
||||
LayerContent::Group(g) => ContentM::Group {
|
||||
children: g.children.iter().map(|c| layer_m(c, sink)).collect(),
|
||||
expanded: g.expanded,
|
||||
artboard: g.artboard.clone(),
|
||||
frame_stroke: g.frame_stroke.clone(),
|
||||
},
|
||||
LayerContent::Adjustment(a) => ContentM::Adjustment { adjustment: a.clone() },
|
||||
LayerContent::Fill(f) => ContentM::Fill { fill: f.clone() },
|
||||
LayerContent::Text(t) => ContentM::Text {
|
||||
@@ -449,7 +452,9 @@ impl Loader<'_> {
|
||||
fn layer_with(&mut self, m: &LayerM, children: Vec<Layer>) -> Result<Layer> {
|
||||
let content = match &m.content {
|
||||
ContentM::Raster { surface } => LayerContent::Raster(self.surface(surface)?),
|
||||
ContentM::Group { expanded, artboard, .. } => LayerContent::Group(Group { artboard: artboard.clone(), children, expanded: *expanded }),
|
||||
ContentM::Group { expanded, artboard, frame_stroke, .. } => {
|
||||
LayerContent::Group(Group { artboard: artboard.clone(), children, expanded: *expanded, frame_stroke: frame_stroke.clone() })
|
||||
}
|
||||
ContentM::Adjustment { adjustment } => LayerContent::Adjustment(adjustment.clone()),
|
||||
ContentM::Fill { fill } => LayerContent::Fill(fill.clone()),
|
||||
ContentM::Text { text, font_family, size_pt, color, transform, cache, psd_raw, runs, paragraphs, shape, orientation, antialias, warp } => {
|
||||
|
||||
@@ -259,6 +259,9 @@ pub enum ContentM {
|
||||
/// Artboard (Layer › New › Artboard).
|
||||
#[serde(default)]
|
||||
artboard: Option<photocraft_doc::Artboard>,
|
||||
/// A Photoshop Frame's stroke, drawn along the group's vector mask.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
frame_stroke: Option<ShapeStroke>,
|
||||
},
|
||||
Adjustment {
|
||||
adjustment: Adjustment,
|
||||
|
||||
+94
-16
@@ -64,6 +64,34 @@ fn png(img: &Rgba8Image) -> Result<Vec<u8>> {
|
||||
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).map_err(|e| FormatError::Unsupported(format!("preview: {e}")))
|
||||
}
|
||||
|
||||
fn previews(opts: &SaveOptions) -> Result<Vec<(&'static str, Vec<u8>)>> {
|
||||
let (composite, thumbnail) = beside(
|
||||
opts.thumbnail.is_some() && opts.composite.is_some(),
|
||||
|| opts.composite.as_ref().map(png).transpose(),
|
||||
|| opts.thumbnail.as_ref().map(png).transpose(),
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
if let Some(t) = thumbnail? {
|
||||
out.push((THUMB, t));
|
||||
}
|
||||
if let Some(t) = composite? {
|
||||
out.push((COMPOSITE, t));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn beside<A: Send, B>(parallel: bool, a: impl FnOnce() -> Result<A> + Send, b: impl FnOnce() -> B) -> (Result<A>, B) {
|
||||
if !parallel || cfg!(target_arch = "wasm32") {
|
||||
let a = a();
|
||||
return (a, b());
|
||||
}
|
||||
std::thread::scope(|sc| {
|
||||
let h = sc.spawn(a);
|
||||
let b = b();
|
||||
(h.join().unwrap_or_else(|_| Err(FormatError::Io(std::io::Error::other("preview worker panicked")))), b)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sources
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -481,7 +509,6 @@ struct FileSignature {
|
||||
struct Prepared {
|
||||
manifest: Vec<u8>,
|
||||
objects: BTreeMap<String, Object>,
|
||||
previews: Vec<(&'static str, Vec<u8>)>,
|
||||
stats: SaveStats,
|
||||
}
|
||||
|
||||
@@ -882,13 +909,6 @@ impl PcraftWriter {
|
||||
convert::check_nesting(&doc.layers)?;
|
||||
let mut c = Collect { hash_cache: Some(&mut self.hash_cache), blob_hash_cache: Some(&mut self.blob_hash_cache), ..Default::default() };
|
||||
let document = convert::doc_m(doc, &mut c);
|
||||
let mut previews = Vec::new();
|
||||
if let Some(t) = &opts.thumbnail {
|
||||
previews.push((THUMB, png(t)?));
|
||||
}
|
||||
if let Some(t) = &opts.composite {
|
||||
previews.push((COMPOSITE, png(t)?));
|
||||
}
|
||||
let manifest = Manifest {
|
||||
format_version: FORMAT_VERSION,
|
||||
generator: format!("photocraft-format {}", env!("CARGO_PKG_VERSION")),
|
||||
@@ -912,23 +932,33 @@ impl PcraftWriter {
|
||||
for (h, b) in c.blobs {
|
||||
objects.insert(blob_path(&h), Object::Blob(b));
|
||||
}
|
||||
Ok(Prepared { manifest, objects, previews, stats })
|
||||
Ok(Prepared { manifest, objects, stats })
|
||||
}
|
||||
|
||||
/// Save as a ZIP bundle in memory.
|
||||
pub fn save_zip(&mut self, doc: &Document, opts: &SaveOptions) -> Result<(Vec<u8>, SaveStats)> {
|
||||
let p = self.prepare(doc, opts)?;
|
||||
// Another thread encodes the previews while the document is collected and its new
|
||||
// objects are compressed on all cores (zstd dominates a full save).
|
||||
let (previews, prepared) = beside(
|
||||
opts.thumbnail.is_some() || opts.composite.is_some(),
|
||||
|| previews(opts),
|
||||
|| -> Result<_> {
|
||||
let p = self.prepare(doc, opts)?;
|
||||
let todo: Vec<(&String, &Object)> = p.objects.iter().filter(|(path, _)| !self.compressed.contains_key(*path)).collect();
|
||||
let fresh: HashMap<String, Arc<Vec<u8>>> = par_compress(&todo)?.into_iter().map(|(path, data)| (path.clone(), data)).collect();
|
||||
Ok((p, fresh))
|
||||
},
|
||||
);
|
||||
let (p, mut fresh) = prepared?;
|
||||
let previews = previews?;
|
||||
let mut stats = p.stats;
|
||||
let mut z = ZipWriter::new();
|
||||
z.add(MANIFEST, &p.manifest)?;
|
||||
for (name, data) in &p.previews {
|
||||
for (name, data) in &previews {
|
||||
z.add(name, data)?;
|
||||
}
|
||||
let mut next = HashMap::with_capacity(p.objects.len());
|
||||
let mut cached_bytes = 0usize;
|
||||
// New objects are compressed on all cores first (zstd dominates a full save).
|
||||
let todo: Vec<(&String, &Object)> = p.objects.iter().filter(|(path, _)| !self.compressed.contains_key(*path)).collect();
|
||||
let mut fresh: HashMap<&String, Arc<Vec<u8>>> = par_compress(&todo)?.into_iter().collect();
|
||||
for (path, obj) in &p.objects {
|
||||
let (key, data) = match self.compressed.remove_entry(path) {
|
||||
Some((key, data)) => {
|
||||
@@ -976,6 +1006,7 @@ impl PcraftWriter {
|
||||
validate_bundle_file(dir, &dir.join(path))?;
|
||||
}
|
||||
let p = self.prepare(doc, opts)?;
|
||||
let previews = previews(opts)?;
|
||||
let mut stats = p.stats;
|
||||
if self.verified_directory.as_ref().is_none_or(|cache| cache.directory != canonical_dir) {
|
||||
self.verified_directory = Some(DirectoryVerificationCache { directory: canonical_dir.clone(), ..Default::default() });
|
||||
@@ -1035,7 +1066,7 @@ impl PcraftWriter {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (name, data) in &p.previews {
|
||||
for (name, data) in &previews {
|
||||
write_bundle_file(dir, &dir.join(name), data, fs)?;
|
||||
}
|
||||
// Pin all GC directories before removing stale previews or objects.
|
||||
@@ -1062,7 +1093,7 @@ impl PcraftWriter {
|
||||
// Omitted previews belong to the previous manifest until the new one commits.
|
||||
// After commit, a locked leftover is only housekeeping, just like unused objects.
|
||||
for stale in [THUMB, COMPOSITE] {
|
||||
if !p.previews.iter().any(|(n, _)| *n == stale) {
|
||||
if !previews.iter().any(|(n, _)| *n == stale) {
|
||||
let _ = remove_bundle_file(dir, &dir.join(stale), &gc);
|
||||
}
|
||||
}
|
||||
@@ -1190,6 +1221,53 @@ fn compressed_cache_budget_limits_retained_capacity_without_changing_bytes() {
|
||||
assert!(writer.compressed.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn previews_follow_the_manifest_with_the_bytes_of_a_serial_encode() {
|
||||
use photocraft_doc::{Layer, LayerContent};
|
||||
use photocraft_geom::Size;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
let mut doc = Document::new("previews", Size::new(600, 3), photocraft_color::ColorMode::Rgb, SampleType::U8);
|
||||
let mut surface = Surface::new(doc.pixel_format());
|
||||
for x in 0..600 {
|
||||
surface.write_pixel(x, 1, &[x as f32 / 600.0, 0.5, 0.25, 1.0]);
|
||||
}
|
||||
doc.layers.push(Layer::new("pixels", LayerContent::Raster(surface)));
|
||||
let image = |w: u32, h: u32, k: u8| {
|
||||
let mut i = Rgba8Image::new(w, h);
|
||||
i.pixels.iter_mut().enumerate().for_each(|(j, p)| *p = (j as u8).wrapping_mul(k));
|
||||
i
|
||||
};
|
||||
let (thumb, composite) = (image(4, 3, 7), image(9, 5, 13));
|
||||
for (t, c) in [(true, true), (true, false), (false, true), (false, false)] {
|
||||
let opts = SaveOptions { thumbnail: t.then(|| thumb.clone()), composite: c.then(|| composite.clone()) };
|
||||
let (bytes, _) = PcraftWriter::new().save_zip(&doc, &opts).unwrap();
|
||||
let zip = ZipReader::new(&bytes).unwrap();
|
||||
let names: Vec<&str> = zip.entries.iter().map(|e| e.name.as_str()).collect();
|
||||
let mut want = vec![MANIFEST];
|
||||
want.extend(t.then_some(THUMB));
|
||||
want.extend(c.then_some(COMPOSITE));
|
||||
assert_eq!(names[..want.len()], want[..]);
|
||||
assert!(names[want.len()..].iter().all(|n| n.starts_with("tiles/") || n.starts_with("blobs/")), "{names:?}");
|
||||
for (name, img) in [(THUMB, opts.thumbnail.as_ref()), (COMPOSITE, opts.composite.as_ref())] {
|
||||
assert_eq!(zip.read_by_name(name, usize::MAX).ok(), img.map(|i| png(i).unwrap()), "{name}");
|
||||
}
|
||||
}
|
||||
let bad = SaveOptions { thumbnail: Some(Rgba8Image { width: 4, height: 3, pixels: vec![0; 5] }), composite: Some(composite.clone()) };
|
||||
assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview")));
|
||||
let bad = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(Rgba8Image { width: 9, height: 5, pixels: vec![0; 5] }) };
|
||||
assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview")));
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("pcraft-previews-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let opts = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(composite.clone()) };
|
||||
PcraftWriter::new().save_dir(&doc, &dir, &opts).unwrap();
|
||||
assert_eq!(std::fs::read(dir.join(THUMB)).unwrap(), png(&thumb).unwrap());
|
||||
assert_eq!(std::fs::read(dir.join(COMPOSITE)).unwrap(), png(&composite).unwrap());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[test]
|
||||
fn gc_pinned_directory_handles_resist_subdirectory_swap() {
|
||||
|
||||
@@ -304,3 +304,54 @@ fn failed_cleanup_after_commit_does_not_fail_the_save() {
|
||||
assert_eq!(load_path(&dir).unwrap(), doc);
|
||||
std::fs::remove_dir_all(dir.parent().unwrap()).unwrap();
|
||||
}
|
||||
|
||||
/// #9034: while a document's autosave replaces its entry, a listing (another launch's recovery
|
||||
/// prompt, or the launch's own check after closing a second document) sees that entry exactly
|
||||
/// once. On Windows the sidecar used to be replaced through `ReplaceFileW`, which moves the old
|
||||
/// sidecar to a backup name before moving the new one in: a listing in between found the bundle
|
||||
/// without a sidecar and listed it a second time (or, the other way round, not at all).
|
||||
#[test]
|
||||
fn a_listing_during_an_autosave_sees_the_entry_exactly_once() {
|
||||
let dir = temp_dir("listing-during-autosave");
|
||||
let doc = Document::new("Busy", photocraft_doc::Size { width: 4, height: 4 }, ColorMode::Rgb, SampleType::U8);
|
||||
save_once(&dir, "busy", doc.clone());
|
||||
let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let writer = {
|
||||
let (dir, done) = (dir.clone(), done.clone());
|
||||
std::thread::spawn(move || {
|
||||
// Sets `done` however the thread ends, so a failing writer stops the listing loop
|
||||
// and `join` reports its panic instead of the test hanging.
|
||||
struct SetOnDrop(Arc<std::sync::atomic::AtomicBool>);
|
||||
impl Drop for SetOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let _done = SetOnDrop(done);
|
||||
let saver = Autosaver::new(&dir, "busy");
|
||||
for revision in 2..=200 {
|
||||
saver.request(Arc::new(doc.clone()), revision, None, SaveOptions::default());
|
||||
// Wait for this write, so every revision replaces the sidecar once.
|
||||
let completed = loop {
|
||||
let completed = saver.take_completed();
|
||||
if !completed.is_empty() {
|
||||
break completed;
|
||||
}
|
||||
std::thread::yield_now();
|
||||
};
|
||||
assert!(completed.iter().all(|(_, result)| result.is_ok()), "revision {revision}: {completed:?}");
|
||||
}
|
||||
saver.flush()
|
||||
})
|
||||
};
|
||||
let mut listings = 0u32;
|
||||
while !done.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
let entries = list_recovery(&dir);
|
||||
let keys: Vec<&str> = entries.iter().map(|e| e.info.key.as_str()).collect();
|
||||
assert_eq!(keys, ["busy"], "listing {listings} during an autosave: {entries:?}");
|
||||
listings += 1;
|
||||
}
|
||||
assert!(writer.join().unwrap().is_some_and(|r| r.is_ok()));
|
||||
assert!(listings > 0);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! A Photoshop Frame's stroke (`Group::frame_stroke`) survives a `.pcraft` round trip; older
|
||||
//! manifests, written before it existed, load without one (#4381).
|
||||
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Fill, Layer, LayerContent, ShapeStroke, Size, StrokeAlign};
|
||||
use photocraft_format::*;
|
||||
|
||||
fn stroke_of(d: &Document) -> Option<ShapeStroke> {
|
||||
match &d.layers.first()?.content {
|
||||
LayerContent::Group(g) => g.frame_stroke.clone(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_stroke_roundtrips_and_defaults_when_absent() {
|
||||
let mut doc = Document::new("a", Size::new(40, 30), ColorMode::Rgb, SampleType::U8);
|
||||
let stroke =
|
||||
ShapeStroke { width: 8.0, align: StrokeAlign::Inside, paint: Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), dashes: vec![2.0, 1.0], ..ShapeStroke::default() };
|
||||
let mut g = Layer::group("Frame 1", vec![]);
|
||||
if let LayerContent::Group(gr) = &mut g.content {
|
||||
gr.frame_stroke = Some(stroke.clone());
|
||||
}
|
||||
doc.layers.push(g);
|
||||
let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap();
|
||||
assert_eq!(stroke_of(&load_from_bytes(&bytes).unwrap()), Some(stroke));
|
||||
|
||||
// A manifest without the key (older files) loads with no stroke.
|
||||
let m = read_manifest(&bytes).unwrap();
|
||||
let mut v = serde_json::to_value(&m.document).unwrap();
|
||||
let content = &mut v["layers"][0]["content"];
|
||||
assert!(content.get("frame_stroke").is_some());
|
||||
content.as_object_mut().unwrap().remove("frame_stroke");
|
||||
let old: photocraft_format::manifest::DocM = serde_json::from_value(v).unwrap();
|
||||
let photocraft_format::manifest::ContentM::Group { frame_stroke, .. } = &old.layers[0].content else { panic!("not a group") };
|
||||
assert!(frame_stroke.is_none());
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod common;
|
||||
mod corruption;
|
||||
mod directory_symlinks;
|
||||
mod float_roundtrip;
|
||||
mod frame_stroke;
|
||||
mod layer_labels;
|
||||
mod roundtrip;
|
||||
mod smart_contents;
|
||||
|
||||
@@ -884,6 +884,25 @@ fn variables_follow_layer_id_remapping() {
|
||||
assert!(back.walk().into_iter().all(|(_, _, l)| l.id != gone), "dangling target must not address a live layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_lookup_lut_file_round_trips_and_old_files_load() {
|
||||
use photocraft_doc::Adjustment;
|
||||
use photocraft_doc::adjust::{LutFormat, LutSource};
|
||||
let lut = Some(std::sync::Arc::new(vec![0.5f32; 24]));
|
||||
let source = Some(LutSource { format: LutFormat::ThreeDl, bytes: std::sync::Arc::new(b"0 1023\n0 0 0\n".to_vec()) });
|
||||
let adj = Adjustment::ColorLookup { name: "Film.3dl".into(), lut, size: 2, tetrahedral: false, dither: false, source };
|
||||
let mut doc = Document::new("d", photocraft_doc::Size::new(4, 4), ColorMode::Rgb, SampleType::U8);
|
||||
doc.insert_above(None, Layer::new("lookup", LayerContent::Adjustment(adj.clone())));
|
||||
let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap();
|
||||
assert_eq!(back, doc, "the LUT file survives a .pcraft round trip (#6544)");
|
||||
// A .pcraft written before the field existed has no `source`: it loads as None.
|
||||
let mut v = serde_json::to_value(&adj).unwrap();
|
||||
let removed = v.as_object_mut().and_then(|o| o.values_mut().next()).and_then(|f| f.as_object_mut()).and_then(|f| f.remove("source"));
|
||||
assert!(removed.is_some(), "{v}");
|
||||
let Adjustment::ColorLookup { source: None, name, .. } = serde_json::from_value(v).unwrap() else { panic!("old lookup must load") };
|
||||
assert_eq!(name, "Film.3dl");
|
||||
}
|
||||
|
||||
/// Removing previews is post-commit housekeeping, not part of publishing the new document.
|
||||
#[test]
|
||||
fn directory_save_without_previews_removes_stale_files_after_commit() {
|
||||
|
||||
@@ -374,6 +374,15 @@ impl<'a> Planner<'a> {
|
||||
p.mask = self.mask_use(layer);
|
||||
after = self.emit(p);
|
||||
}
|
||||
// A Frame's stroke over the frame's content, at the group's opacity (compose).
|
||||
if let Some(stroke) = photocraft_compose::shape_split::frame_stroke(layer, self.cx.canvas, self.cx.depth) {
|
||||
let stroke = self.shape_part(layer, Role::Stroke, stroke);
|
||||
let mut p = Pass::new(Kernel::Blend, 0);
|
||||
p.a = Some(after);
|
||||
p.b = Some(stroke);
|
||||
p.opacity = opacity;
|
||||
after = self.emit(p);
|
||||
}
|
||||
if visible_clipped.is_empty() {
|
||||
self.release(before);
|
||||
return Ok(after);
|
||||
@@ -554,12 +563,20 @@ impl<'a> Planner<'a> {
|
||||
match &layer.content {
|
||||
LayerContent::Group(g) => {
|
||||
let empty = self.clear();
|
||||
let s = self.stack(&g.children, empty)?;
|
||||
let mut s = self.stack(&g.children, empty)?;
|
||||
if layer.mask.is_some() || layer.vector_mask.is_some() {
|
||||
let mut p = Pass::new(Kernel::Mask, 0);
|
||||
p.a = Some(s);
|
||||
p.mask = self.mask_use(layer);
|
||||
return Ok(self.emit(p));
|
||||
s = self.emit(p);
|
||||
}
|
||||
// A Frame's stroke over its masked content (compose::render_content).
|
||||
if let Some(stroke) = photocraft_compose::shape_split::frame_stroke(layer, self.cx.canvas, self.cx.depth) {
|
||||
let stroke = self.shape_part(layer, Role::Stroke, stroke);
|
||||
let mut p = Pass::new(Kernel::Blend, 0);
|
||||
p.a = Some(s);
|
||||
p.b = Some(stroke);
|
||||
s = self.emit(p);
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
@@ -108,7 +108,8 @@ fn outer_glow_plan_knocks_out_see_through_fill() {
|
||||
#[test]
|
||||
fn color_lookup_with_an_overflowing_stored_size_uploads_no_table() {
|
||||
for size in [1 << 22, u32::MAX] {
|
||||
let a = Adjustment::ColorLookup { name: "x".into(), lut: Some(std::sync::Arc::new(vec![0.5; 24])), size, tetrahedral: false, dither: false };
|
||||
let a =
|
||||
Adjustment::ColorLookup { name: "x".into(), lut: Some(std::sync::Arc::new(vec![0.5; 24])), size, tetrahedral: false, dither: false, source: None };
|
||||
let (kind, _, rows) = adjustment_program(&a, Transfer::Srgb, SampleType::U8, photocraft_color::ColorMode::Rgb);
|
||||
assert_eq!((kind, rows.is_none()), (0, true), "size {size}");
|
||||
}
|
||||
|
||||
@@ -715,7 +715,7 @@ fn lookup(n: usize, tetrahedral: bool, dither: bool) -> Adjustment {
|
||||
}
|
||||
}
|
||||
}
|
||||
Adjustment::ColorLookup { name: "test".into(), lut: Some(std::sync::Arc::new(t)), size: n as u32, tetrahedral, dither }
|
||||
Adjustment::ColorLookup { name: "test".into(), lut: Some(std::sync::Arc::new(t)), size: n as u32, tetrahedral, dither, source: None }
|
||||
}
|
||||
|
||||
/// 32-bit Hue/Saturation on colours at and above HSL lightness 1 and with a negative channel,
|
||||
@@ -2482,6 +2482,36 @@ fn stroked_shapes_with_clipped_layers() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Photoshop Frame's stroke (#4381): along the group's vector mask, above its masked content,
|
||||
/// for an empty frame and one holding a layer, pass-through and isolated.
|
||||
#[test]
|
||||
fn frame_strokes() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
use photocraft_doc::vector::{Path, ShapeStroke, StrokeAlign, Subpath, VectorMask};
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for (blend, align, content) in [
|
||||
(BlendMode::PassThrough, StrokeAlign::Inside, false),
|
||||
(BlendMode::PassThrough, StrokeAlign::Outside, true),
|
||||
(BlendMode::Multiply, StrokeAlign::Center, true),
|
||||
] {
|
||||
let mut d = Document::new("t", Size::new(80, 64), ColorMode::Rgb, depth);
|
||||
d.layers.push(noise_layer("bg", d.pixel_format(), Rect::from_xywh(0, 0, 80, 64), 1, 0.3));
|
||||
let kids = if content { vec![noise_layer("c", d.pixel_format(), Rect::from_xywh(0, 0, 80, 64), 7, 0.4)] } else { vec![] };
|
||||
let mut l = Layer::group("Frame 1", kids);
|
||||
l.blend = blend;
|
||||
l.opacity = 0.8;
|
||||
l.vector_mask = Some(VectorMask::new(Path::new(vec![Subpath::polygon(&[(10.3, 8.6), (66.2, 12.1), (58.7, 54.4), (16.9, 48.2)])])));
|
||||
if let LayerContent::Group(gr) = &mut l.content {
|
||||
gr.frame_stroke = Some(ShapeStroke { width: 5.0, align, paint: Fill::Solid(Color::rgb(0.9, 0.1, 0.1)), ..Default::default() });
|
||||
}
|
||||
d.layers.push(l);
|
||||
let st = photocraft_gpu::render_to_vec_stats(&mut g.comp, &g.device, &g.queue, &d, d.bounds());
|
||||
assert!(st.is_ok(), "planned on the GPU");
|
||||
check(&mut g, &d, &format!("{depth:?} frame stroke {blend:?} {align:?} content {content}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboards() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
@@ -3055,6 +3085,25 @@ fn pass_through_clipping_base_with_an_adjustment() {
|
||||
check(&mut g, &d, "pass-through clipping base with an adjustment");
|
||||
}
|
||||
|
||||
/// Transparency Shapes Layer off changes the special eight's blend (the layer's alpha blends as
|
||||
/// Fill, #5692); the GPU has no pass for it and leaves such documents to the CPU. Other modes look
|
||||
/// the same either way and stay on the GPU.
|
||||
#[test]
|
||||
fn transparency_shapes_off_special_blend_falls_back_to_the_cpu() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let mut d = base_doc(64, 48);
|
||||
let mut l = noise_layer("top", PixelFormat::RGBA8, Rect::new(4, 3, 60, 45), 2, 0.0);
|
||||
l.blend = BlendMode::ColorDodge;
|
||||
l.opacity = 0.53;
|
||||
l.advanced.transparency_shapes = false;
|
||||
d.layers.push(l);
|
||||
assert!(render_to_vec(&mut g.comp, &g.device, &g.queue, &d, d.bounds()).is_err(), "Color Dodge with Transparency Shapes Layer off");
|
||||
if let Some(l) = d.layers.last_mut() {
|
||||
l.blend = BlendMode::Multiply;
|
||||
}
|
||||
check(&mut g, &d, "Multiply with Transparency Shapes Layer off");
|
||||
}
|
||||
|
||||
/// #5846: a shape's Density and Feather soften its fill (its own rendered pixels), with and
|
||||
/// without a vector stroke over clipped layers and layer effects: the GPU matches the CPU.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Export timings on a realistic photo document (default 7360×4912 = 36 MP): the opened photo,
|
||||
//! a painted layer covering part of the canvas, and a soft half-transparent layer.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --release -p photocraft-io --example bench_export -- [--size 7360x4912] [--reps 7] [--only pcraft] [--out DIR]
|
||||
//! ```
|
||||
//!
|
||||
//! Each row is the median (and min) of `--reps` runs. `--out DIR` writes each format's bytes to
|
||||
//! `DIR/out.<ext>` so two builds' output can be compared.
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_doc::Layer;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
fn arg(args: &[String], name: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
|
||||
}
|
||||
|
||||
fn photo(w: u32, h: u32) -> photocraft_codecs::Image {
|
||||
let mut px = vec![0u8; w as usize * h as usize * 3];
|
||||
for (y, row) in px.chunks_mut(w as usize * 3).enumerate() {
|
||||
for x in 0..w as usize {
|
||||
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
|
||||
let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 997) as f32 / 997.0 - 0.5;
|
||||
let blob = (-(((fx - 0.4) * 3.0).powi(2) + ((fy - 0.55) * 4.0).powi(2))).exp();
|
||||
let r = 0.55 + 0.35 * (fx * 6.0).sin() * 0.5 + 0.3 * blob + n * 0.06;
|
||||
let g = 0.45 + 0.25 * (fy * 9.0 + fx * 2.0).cos() * 0.5 + 0.2 * blob + n * 0.06;
|
||||
let b = 0.35 + 0.4 * fy - 0.2 * blob + n * 0.06;
|
||||
for (c, v) in [r, g, b].into_iter().enumerate() {
|
||||
row[x * 3 + c] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
photocraft_codecs::Image::from_raw(w, h, photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8, px).expect("image")
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let (w, h) = arg(&args, "--size").and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?)))).unwrap_or((7360u32, 4912u32));
|
||||
let reps: usize = arg(&args, "--reps").and_then(|s| s.parse().ok()).unwrap_or(7);
|
||||
let only = arg(&args, "--only");
|
||||
let out = arg(&args, "--out");
|
||||
|
||||
let png = photocraft_codecs::encode(&photo(w, h), photocraft_codecs::Format::Png, &Default::default()).expect("png");
|
||||
let mut doc = photocraft_io::import("photo.png", &png).expect("import").document;
|
||||
let fmt = doc.pixel_format();
|
||||
let mut paint = Layer::raster("paint", fmt);
|
||||
if let Some(s) = paint.surface_mut() {
|
||||
let (pw, ph) = (w / 2, h / 3);
|
||||
let data: Vec<f32> = (0..pw * ph)
|
||||
.flat_map(|i| {
|
||||
let (x, y) = (i % pw, i / pw);
|
||||
let a = if (x / 64 + y / 64) % 3 == 0 { 0.0 } else { 0.85 };
|
||||
[0.75, (x % 256) as f32 / 255.0, (y % 256) as f32 / 255.0, a]
|
||||
})
|
||||
.collect();
|
||||
s.write_region(Rect::from_xywh(w as i32 / 8, h as i32 / 4, pw, ph), &data);
|
||||
}
|
||||
doc.layers.push(paint);
|
||||
let mut soft = Layer::raster("soft", fmt);
|
||||
soft.opacity = 0.6;
|
||||
if let Some(s) = soft.surface_mut() {
|
||||
s.fill_rect(Rect::from_xywh(w as i32 / 2, 0, w / 2, h), &[0.1, 0.2, 0.9, 0.5]);
|
||||
}
|
||||
doc.layers.push(soft);
|
||||
|
||||
println!("document {w}×{h}, {} layers, {reps} reps (median / min ms)", doc.layers.len());
|
||||
// "pcraft+cache" is the app's Save: one export cache per document, so the first save
|
||||
// compresses every tile and later ones reuse what fits the cache (timed after a warm-up save).
|
||||
for ext in ["psd", "pcraft", "pcraft+cache", "tif", "png", "jpg"] {
|
||||
if only.as_deref().is_some_and(|o| o != ext) {
|
||||
continue;
|
||||
}
|
||||
let cached = ext == "pcraft+cache";
|
||||
let opts = photocraft_io::ExportOptions { pcraft_cache: cached.then(photocraft_io::PcraftExportCache::default), ..Default::default() };
|
||||
let name = format!("x.{}", ext.trim_end_matches("+cache"));
|
||||
if cached {
|
||||
photocraft_io::export(&doc, &name, &opts).expect("warm-up");
|
||||
}
|
||||
let mut times = Vec::with_capacity(reps);
|
||||
let mut bytes = Vec::new();
|
||||
for _ in 0..reps {
|
||||
let t = Instant::now();
|
||||
bytes = photocraft_io::export(&doc, &name, &opts).expect("export").bytes;
|
||||
times.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
println!("{ext:>7}: {:9.1} / {:9.1} ({} bytes)", times[times.len() / 2], times[0], bytes.len());
|
||||
if let Some(dir) = &out {
|
||||
std::fs::write(format!("{dir}/out.{}", ext.replace('+', "-")), &bytes).expect("write");
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
-17
@@ -11,7 +11,7 @@
|
||||
//! stay [`Adjustment::Unsupported`] and are written back verbatim.
|
||||
|
||||
use photocraft_doc::Adjustment;
|
||||
use photocraft_doc::adjust::{CURVE_MAP_LEN, CurvePoint, HueRange, LevelsChannel, ToneSpace, curve_map, curve_map_outputs};
|
||||
use photocraft_doc::adjust::{CURVE_MAP_LEN, CurvePoint, HueRange, LevelsChannel, LutFormat, LutSource, ToneSpace, curve_map, curve_map_outputs};
|
||||
use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor};
|
||||
|
||||
/// All PSD adjustment keys recognized as adjustment layers.
|
||||
@@ -459,12 +459,12 @@ fn read_lookup(d: &[u8]) -> Result<Adjustment, String> {
|
||||
Some(_) => return Err("its LUT data is empty".into()),
|
||||
None => return Err("it uses a colour profile (abstract or device link), not a LUT file, which isn't supported yet".into()),
|
||||
};
|
||||
let (ext, format) = match desc_enum(&desc, "LUTFormat") {
|
||||
Some(b"LUTFormat3DL") => ("x.3dl", "3DL"),
|
||||
Some(b"LUTFormatLOOK") => ("x.look", "LOOK"),
|
||||
_ => ("x.cube", "CUBE"),
|
||||
let format = match desc_enum(&desc, "LUTFormat") {
|
||||
Some(b"LUTFormat3DL") => LutFormat::ThreeDl,
|
||||
Some(b"LUTFormatLOOK") => LutFormat::Look,
|
||||
_ => LutFormat::Cube,
|
||||
};
|
||||
let lut = photocraft_cms::lutfile::parse(ext, bytes).map_err(|e| format!("its {format} LUT can't be read ({e})"))?;
|
||||
let lut = parse_lut_source(format, bytes).map_err(|e| format!("its {} LUT can't be read ({e})", psd_lut_format(format).trim_start_matches("LUTFormat")))?;
|
||||
if !lut.domain_is_default() {
|
||||
return Err(format!(
|
||||
"its LUT has a non-default input domain (DOMAIN_MIN {:?}, DOMAIN_MAX {:?}), which is not supported yet",
|
||||
@@ -478,9 +478,47 @@ fn read_lookup(d: &[u8]) -> Result<Adjustment, String> {
|
||||
lut: Some(std::sync::Arc::new(lut.data)),
|
||||
tetrahedral: false,
|
||||
dither: desc_bool(&desc, "Dthr").unwrap_or(false),
|
||||
// Kept so a save embeds the file as loaded (Photoshop never converts it).
|
||||
source: Some(LutSource { format, bytes: std::sync::Arc::new(bytes.clone()) }),
|
||||
})
|
||||
}
|
||||
|
||||
/// A LUT file's table, read the way a PSD read does (an XML file tagged `.cube` is sniffed as `.look`).
|
||||
fn parse_lut_source(format: LutFormat, bytes: &[u8]) -> Result<photocraft_cms::lutfile::LutFile, photocraft_cms::lutfile::LutError> {
|
||||
let format = match format {
|
||||
LutFormat::Cube => photocraft_cms::lutfile::Format::Cube,
|
||||
LutFormat::ThreeDl => photocraft_cms::lutfile::Format::ThreeDl,
|
||||
LutFormat::Look => photocraft_cms::lutfile::Format::Look,
|
||||
};
|
||||
photocraft_cms::lutfile::parse(&format!("x.{}", format.extension()), bytes)
|
||||
}
|
||||
|
||||
/// Photoshop's `LUTFormatType` value for a format.
|
||||
fn psd_lut_format(format: LutFormat) -> &'static str {
|
||||
match format {
|
||||
LutFormat::Cube => "LUTFormatCUBE",
|
||||
LutFormat::ThreeDl => "LUTFormat3DL",
|
||||
LutFormat::Look => "LUTFormatLOOK",
|
||||
}
|
||||
}
|
||||
|
||||
/// The source file of a Color Lookup to embed as is: only while it still parses to the
|
||||
/// layer's table, so an edited table is never saved with stale bytes.
|
||||
fn lut_source_for<'a>(source: Option<&'a LutSource>, table: &[f32], size: usize) -> Option<&'a LutSource> {
|
||||
let source = source?;
|
||||
let file = parse_lut_source(source.format, &source.bytes).ok()?;
|
||||
(file.size == size && file.data == table).then_some(source)
|
||||
}
|
||||
|
||||
/// `name` with a `.3dl` / `.look` extension swapped for `.cube`, for a table saved as `.cube`.
|
||||
fn cube_file_name(name: &str) -> String {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
match [".3dl", ".look"].iter().find(|ext| lower.ends_with(*ext)) {
|
||||
Some(ext) => format!("{}.cube", name.get(..name.len() - ext.len()).unwrap_or(name)),
|
||||
None => name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The descriptor of a `vibA` / `blwh` block: a 4-byte descriptor version (16), then the descriptor.
|
||||
fn versioned(d: &[u8]) -> Option<Descriptor> {
|
||||
VersionedDescriptor::parse_prefix(d).ok().map(|(v, _)| v.descriptor)
|
||||
@@ -735,31 +773,40 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec<u8>)> {
|
||||
}
|
||||
return vec![(*b"selc", v)];
|
||||
}
|
||||
Adjustment::ColorLookup { name, lut, size, dither, .. } => {
|
||||
Adjustment::ColorLookup { name, lut, size, dither, source, .. } => {
|
||||
// A lookup without a usable table (none chosen yet, or a bad size) renders as the
|
||||
// identity, so it is written as a 2³ identity cube rather than dropped.
|
||||
let n = *size as usize;
|
||||
let cube = lut.as_deref().zip(photocraft_doc::adjust::lut3d_len(*size)).and_then(|(table, len)| table.get(..len)).filter(|_| n <= 256);
|
||||
let (file, dither) = match cube {
|
||||
Some(cube) => (
|
||||
photocraft_cms::lutfile::LutFile { title: String::new(), size: n, data: cube.to_vec(), domain_min: [0.0; 3], domain_max: [1.0; 3] },
|
||||
*dither,
|
||||
),
|
||||
_ => (photocraft_cms::lutfile::LutFile { title: String::new(), ..photocraft_cms::lutfile::LutFile::identity(2) }, false),
|
||||
// Like Photoshop, embed the LUT file as loaded, in its own format; a table made or
|
||||
// changed in PhotoCraft is written as `.cube`, named to match.
|
||||
let (format, data, name, dither) = match cube {
|
||||
Some(cube) => match lut_source_for(source.as_ref(), cube, n) {
|
||||
Some(src) => (src.format, src.bytes.to_vec(), name.clone(), *dither),
|
||||
None => {
|
||||
let file =
|
||||
photocraft_cms::lutfile::LutFile { title: String::new(), size: n, data: cube.to_vec(), domain_min: [0.0; 3], domain_max: [1.0; 3] };
|
||||
(LutFormat::Cube, photocraft_cms::lutfile::write_cube(&file).into_bytes(), cube_file_name(name), *dither)
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
let file = photocraft_cms::lutfile::LutFile { title: String::new(), ..photocraft_cms::lutfile::LutFile::identity(2) };
|
||||
(LutFormat::Cube, photocraft_cms::lutfile::write_cube(&file).into_bytes(), cube_file_name(name), false)
|
||||
}
|
||||
};
|
||||
let en =
|
||||
|t: &str, val: &str| Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new(t), value: photocraft_psd::descriptor::Id::new(val) };
|
||||
let text = |t: &str| Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul(t));
|
||||
let d = Descriptor::new("null")
|
||||
.with("lookupType", en("colorLookupType", "3DLUT"))
|
||||
.with("NM ", text(name))
|
||||
.with("NM ", text(&name))
|
||||
.with("Dthr", Value::Boolean(dither))
|
||||
.with("profile", Value::RawData(Vec::new()))
|
||||
.with("LUTFormat", en("LUTFormatType", "LUTFormatCUBE"))
|
||||
.with("LUTFormat", en("LUTFormatType", psd_lut_format(format)))
|
||||
.with("dataOrder", en("colorLookupOrder", "rgbOrder"))
|
||||
.with("tableOrder", en("colorLookupOrder", "bgrOrder"))
|
||||
.with("LUT3DFileData", Value::RawData(photocraft_cms::lutfile::write_cube(&file).into_bytes()))
|
||||
.with("LUT3DFileName", text(name));
|
||||
.with("LUT3DFileData", Value::RawData(data))
|
||||
.with("LUT3DFileName", text(&name));
|
||||
put16(&mut v, 1);
|
||||
v.extend_from_slice(&VersionedDescriptor::new(d).to_bytes());
|
||||
return vec![(*b"clrL", v)];
|
||||
|
||||
@@ -219,7 +219,9 @@ fn roundtrips() {
|
||||
rt(Adjustment::ChannelMixer { matrix: [[0.5, 0.3, 0.2, 0.0], [0.1, 0.8, 0.1, 0.05], [0.0, 0.2, 0.9, -0.05]], monochrome: false });
|
||||
rt(Adjustment::ChannelMixer { matrix: [[0.4, 0.4, 0.2, 0.1], [0.0, 1.0, 0.0, 0.0], [-2.0, 0.0, 2.0, 0.0]], monochrome: true });
|
||||
let id = photocraft_cms::lutfile::LutFile::identity(5);
|
||||
rt(Adjustment::ColorLookup { name: "Look.cube".into(), lut: Some(std::sync::Arc::new(id.data)), size: 5, tetrahedral: false, dither: true });
|
||||
let cube = photocraft_cms::lutfile::write_cube(&id).into_bytes();
|
||||
let source = Some(LutSource { format: LutFormat::Cube, bytes: std::sync::Arc::new(cube) });
|
||||
rt(Adjustment::ColorLookup { name: "Look.cube".into(), lut: Some(std::sync::Arc::new(id.data)), size: 5, tetrahedral: false, dither: true, source });
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -246,6 +248,105 @@ fn selective_color_synthetic_block() {
|
||||
assert_eq!(write(&a)[0].1, d);
|
||||
}
|
||||
|
||||
/// A 2³ Lustre `.3dl` (12-bit, blue fastest) that inverts every colour.
|
||||
fn inverting_3dl() -> Vec<u8> {
|
||||
let mut text = String::from("# made by hand\n0 1023\n");
|
||||
for r in 0..2 {
|
||||
for g in 0..2 {
|
||||
for b in 0..2 {
|
||||
text.push_str(&format!("{} {} {}\n", (1 - r) * 4095, (1 - g) * 4095, (1 - b) * 4095));
|
||||
}
|
||||
}
|
||||
}
|
||||
text.into_bytes()
|
||||
}
|
||||
|
||||
/// A 2³ SpeedGrade `.look` holding the identity.
|
||||
fn identity_look() -> Vec<u8> {
|
||||
let hex: String = photocraft_cms::lutfile::LutFile::identity(2).data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect();
|
||||
format!("<?xml version=\"1.0\"?>\n<look><LUT><size>\"2\"</size><data>\"{hex}\"</data></LUT></look>\n").into_bytes()
|
||||
}
|
||||
|
||||
/// A `clrL` block as Photoshop writes one for a loaded LUT file.
|
||||
fn clrl_block(format: &str, bytes: &[u8], name: &str) -> Vec<u8> {
|
||||
let en = |t: &str, val: &str| Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new(t), value: photocraft_psd::descriptor::Id::new(val) };
|
||||
let text = |t: &str| Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul(t));
|
||||
let desc = Descriptor::new("null")
|
||||
.with("lookupType", en("colorLookupType", "3DLUT"))
|
||||
.with("NM ", text(name))
|
||||
.with("LUTFormat", en("LUTFormatType", format))
|
||||
.with("LUT3DFileData", Value::RawData(bytes.to_vec()))
|
||||
.with("LUT3DFileName", text(name));
|
||||
let mut d = vec![0, 1];
|
||||
d.extend(VersionedDescriptor::new(desc).to_bytes());
|
||||
d
|
||||
}
|
||||
|
||||
/// The descriptor of the `clrL` block `a` is written as.
|
||||
fn written_clrl(a: &Adjustment) -> Descriptor {
|
||||
let b = write(a);
|
||||
assert_eq!(&b[0].0, b"clrL");
|
||||
VersionedDescriptor::parse_prefix(&b[0].1[2..]).unwrap().0.descriptor
|
||||
}
|
||||
|
||||
fn raw<'a>(d: &'a Descriptor, key: &str) -> &'a [u8] {
|
||||
let Some(Value::RawData(b)) = d.get(key) else { panic!("{key} missing") };
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_lookup_keeps_a_3dl_or_look_file_as_loaded() {
|
||||
// #6544: Photoshop embeds the LUT file as loaded and tags its format; it never converts it.
|
||||
for (format, kind, bytes, name) in
|
||||
[("LUTFormat3DL", LutFormat::ThreeDl, inverting_3dl(), "Invert.3DL"), ("LUTFormatLOOK", LutFormat::Look, identity_look(), "Grade.look")]
|
||||
{
|
||||
let a = parse(b"clrL", &clrl_block(format, &bytes, name), None, Channels::Rgb);
|
||||
let Adjustment::ColorLookup { name: n, lut: Some(_), size: 2, source: Some(src), .. } = &a else { panic!("{a:?}") };
|
||||
assert_eq!((n.as_str(), src.format, src.bytes.as_slice()), (name, kind, bytes.as_slice()));
|
||||
let d = written_clrl(&a);
|
||||
assert_eq!(desc_enum(&d, "LUTFormat"), Some(format.as_bytes()), "{name}");
|
||||
assert_eq!(raw(&d, "LUT3DFileData"), bytes.as_slice(), "{name}: the file's bytes, unchanged");
|
||||
assert_eq!(desc_text(&d, "LUT3DFileName").as_deref(), Some(name));
|
||||
assert_eq!(desc_text(&d, "NM ").as_deref(), Some(name));
|
||||
// And it reads back as the same layer.
|
||||
let b = write(&a);
|
||||
assert_eq!(parse(&b[0].0, &b[0].1, None, Channels::Rgb), a);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_lookup_table_without_matching_file_is_saved_as_cube_named_to_match() {
|
||||
let a = parse(b"clrL", &clrl_block("LUTFormat3DL", &inverting_3dl(), "Invert.3DL"), None, Channels::Rgb);
|
||||
let Adjustment::ColorLookup { name, size, tetrahedral, dither, source, .. } = a else { panic!("{a:?}") };
|
||||
// The table no longer matches the stored file (changed in PhotoCraft): the bytes are stale.
|
||||
let id = photocraft_cms::lutfile::LutFile::identity(2).data;
|
||||
let edited = Adjustment::ColorLookup { name, lut: Some(std::sync::Arc::new(id.clone())), size, tetrahedral, dither, source };
|
||||
let d = written_clrl(&edited);
|
||||
assert_eq!(desc_enum(&d, "LUTFormat"), Some(&b"LUTFormatCUBE"[..]));
|
||||
assert_eq!(desc_text(&d, "LUT3DFileName").as_deref(), Some("Invert.cube"));
|
||||
assert_eq!(photocraft_cms::lutfile::parse("x.cube", raw(&d, "LUT3DFileData")).unwrap().data, id);
|
||||
// A table made in PhotoCraft (no file) named like another format is also renamed.
|
||||
let made =
|
||||
Adjustment::ColorLookup { name: "Grade.look".into(), lut: Some(std::sync::Arc::new(id)), size: 2, tetrahedral: false, dither: false, source: None };
|
||||
let d = written_clrl(&made);
|
||||
assert_eq!(desc_enum(&d, "LUTFormat"), Some(&b"LUTFormatCUBE"[..]));
|
||||
assert_eq!(desc_text(&d, "LUT3DFileName").as_deref(), Some("Grade.cube"));
|
||||
assert_eq!(desc_text(&d, "NM ").as_deref(), Some("Grade.cube"));
|
||||
// An unreadable stored file falls back the same way instead of failing the save.
|
||||
let junk = Adjustment::ColorLookup {
|
||||
name: "Junk.3dl".into(),
|
||||
lut: Some(std::sync::Arc::new(photocraft_cms::lutfile::LutFile::identity(2).data)),
|
||||
size: 2,
|
||||
tetrahedral: false,
|
||||
dither: false,
|
||||
source: Some(LutSource { format: LutFormat::ThreeDl, bytes: std::sync::Arc::new(b"\xFF not a lut".to_vec()) }),
|
||||
};
|
||||
assert_eq!(desc_text(&written_clrl(&junk), "LUT3DFileName").as_deref(), Some("Junk.cube"));
|
||||
assert_eq!(cube_file_name("a.b.3dl"), "a.b.cube");
|
||||
assert_eq!(cube_file_name("Day for Night"), "Day for Night");
|
||||
assert_eq!(cube_file_name("x.cube"), "x.cube");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_lookup_synthetic_block() {
|
||||
let cube = b"TITLE \"t\"\nLUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
|
||||
@@ -547,7 +648,7 @@ fn degenerate_adjustments_still_get_an_encoding() {
|
||||
}
|
||||
// A colour lookup without a (usable) table becomes an identity cube, without dither.
|
||||
for lut in [None, Some(std::sync::Arc::new(vec![0.5; 7]))] {
|
||||
let b = write(&Adjustment::ColorLookup { name: "L".into(), lut, size: 3, tetrahedral: false, dither: true });
|
||||
let b = write(&Adjustment::ColorLookup { name: "L".into(), lut, size: 3, tetrahedral: false, dither: true, source: None });
|
||||
let back = parse(&b[0].0, &b[0].1, None, Channels::Rgb);
|
||||
let Adjustment::ColorLookup { lut: Some(t), size: 2, dither: false, name, .. } = back else { panic!("{back:?}") };
|
||||
assert_eq!(name, "L");
|
||||
|
||||
@@ -149,7 +149,7 @@ impl Builder {
|
||||
let r = self.bbox(to_doc, page);
|
||||
let mut g = Layer::new(
|
||||
format!("Page {}", i + 1),
|
||||
LayerContent::Group(Group { children: std::mem::take(&mut children), expanded: true, artboard: None }),
|
||||
LayerContent::Group(Group { children: std::mem::take(&mut children), expanded: true, artboard: None, frame_stroke: None }),
|
||||
);
|
||||
if let LayerContent::Group(gr) = &mut g.content {
|
||||
gr.artboard = Some(Artboard { rect: r, background: ArtboardBackground::White, preset: String::new() });
|
||||
@@ -244,7 +244,7 @@ impl Builder {
|
||||
|
||||
fn group(&mut self, n: &af::Node, children: Vec<Layer>) -> Layer {
|
||||
// Pass-through and Normal (isolated) groups map one to one.
|
||||
self.base(n, LayerContent::Group(Group { children, expanded: false, artboard: None }))
|
||||
self.base(n, LayerContent::Group(Group { children, expanded: false, artboard: None, frame_stroke: None }))
|
||||
}
|
||||
|
||||
/// One Affinity node and its subtree; `to_doc` maps Affinity document pixels to the canvas.
|
||||
|
||||
@@ -228,7 +228,7 @@ pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result<LoadReport, String>
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| "not a Camera Raw settings file (not UTF-8 text)".to_string())?;
|
||||
// Some writers put a byte order mark or an `<?xpacket?>` wrapper first; roxmltree takes both.
|
||||
let text = text.trim_start_matches('\u{feff}');
|
||||
let doc = roxmltree::Document::parse(text).map_err(|e| format!("not a Camera Raw settings file ({e})"))?;
|
||||
let doc = crate::xml::parse(text, roxmltree::ParsingOptions::default()).map_err(|e| format!("not a Camera Raw settings file ({e})"))?;
|
||||
let mut next = params.clone();
|
||||
let mut report = LoadReport::default();
|
||||
let mut found = false;
|
||||
@@ -389,6 +389,16 @@ mod tests {
|
||||
assert_eq!(p.calibration_hue[0], -5.0);
|
||||
}
|
||||
|
||||
/// A settings file nested 20 000 deep overflowed the XML parser's recursion (an abort nothing
|
||||
/// can catch); it is an error now. The small stack makes a regression abort instead of passing.
|
||||
#[test]
|
||||
fn deeply_nested_settings_are_an_error_not_a_stack_overflow() {
|
||||
let xml = format!("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">{}{}</x:xmpmeta>", "<a>".repeat(20_000), "</a>".repeat(20_000));
|
||||
let e =
|
||||
std::thread::Builder::new().stack_size(512 * 1024).spawn(move || load(&mut CameraRaw::default(), xml.as_bytes()).err()).unwrap().join().unwrap();
|
||||
assert!(e.as_deref().is_some_and(|m| m.contains("nested")), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_load_round_trips() {
|
||||
let mut p = CameraRaw {
|
||||
|
||||
@@ -283,10 +283,10 @@ pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result<Im
|
||||
}
|
||||
let xml = zip.read_by_name("maindoc.xml", MAX_MAINDOC).map_err(|e| invalid(e.to_string()))?;
|
||||
let xml = std::str::from_utf8(&xml).map_err(|_| invalid("maindoc.xml is not UTF-8"))?;
|
||||
// maindoc.xml declares Krita's DTD by public id; roxmltree only reads internal subsets and
|
||||
// guards entity expansion.
|
||||
// maindoc.xml declares Krita's DTD by public id; Krita writes no internal subset, which
|
||||
// `xml::parse` refuses (its entities could hide nesting from the depth check).
|
||||
let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
|
||||
let tree = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| invalid(format!("maindoc.xml: {e}")))?;
|
||||
let tree = crate::xml::parse(xml, opts).map_err(|e| invalid(format!("maindoc.xml: {e}")))?;
|
||||
let image = tree.root_element().children().find(|n| n.has_tag_name("IMAGE")).ok_or_else(|| invalid("maindoc.xml has no IMAGE"))?;
|
||||
let side = |a: &str| -> Result<u32> {
|
||||
let v = number(image, a, 0.0)?;
|
||||
|
||||
@@ -200,6 +200,23 @@ fn export_is_refused() {
|
||||
}
|
||||
}
|
||||
|
||||
/// maindoc.xml nested 20 000 deep, directly or through an internal-subset entity the depth check
|
||||
/// can't see, overflowed the XML parser's recursion (an abort nothing can catch). Both are errors
|
||||
/// now; the small stack makes a regression abort instead of passing.
|
||||
#[test]
|
||||
fn deeply_nested_maindoc_is_an_error_not_a_stack_overflow() {
|
||||
let (open, close) = ("<layers>".repeat(20_000), "</layers>".repeat(20_000));
|
||||
let direct = maindoc("RGBA", &format!("{open}{close}"));
|
||||
let entity = format!("<!DOCTYPE DOC [<!ENTITY e \"{open}{close}\">]><DOC><IMAGE width=\"4\" height=\"4\">&e;</IMAGE></DOC>");
|
||||
// A quoted `?>` in the XML declaration must not hide the DOCTYPE behind it.
|
||||
let declared = format!("<?xml version=\"?>\"?>{entity}");
|
||||
for (xml, why) in [(direct, "nested"), (entity, "internal DTD"), (declared, "internal DTD")] {
|
||||
let bytes = archive(&xml, &[]);
|
||||
let e = std::thread::Builder::new().stack_size(512 * 1024).spawn(move || crate::import("deep.kra", &bytes).err()).unwrap().join().unwrap();
|
||||
assert!(matches!(&e, Some(IoError::Kra(m)) if m.contains(why)), "{why}: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lzf_decodes_literals_and_overlapping_references() {
|
||||
// "abc" then a 6-byte back-reference 3 back: abcabcabc.
|
||||
|
||||
@@ -75,6 +75,7 @@ pub mod text_styles_map;
|
||||
pub mod tiff_layers;
|
||||
pub mod vector_map;
|
||||
mod xcf;
|
||||
pub mod xml;
|
||||
|
||||
use photocraft_codecs::{CodecError, EncodeOptions};
|
||||
pub use photocraft_codecs::{DdsCompression, DdsOptions, DpxBits};
|
||||
|
||||
@@ -73,7 +73,9 @@ pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[St
|
||||
}
|
||||
|
||||
/// Writes a Multichannel document as a mode-7 PSD (no layer section; every channel a plane).
|
||||
pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec<String>) {
|
||||
/// A file past `size_limit` bytes is written as PSB (see `psd_export::fit_psd_size`); `None` keeps
|
||||
/// the version whatever the size.
|
||||
pub(crate) fn document_to_psd(doc: &Document, force_psb: bool, size_limit: Option<u64>) -> (PsdFile, Vec<String>) {
|
||||
let mut warnings = Vec::new();
|
||||
// Photoshop's Multichannel mode is 8 or 16 bits per channel.
|
||||
let sample = match doc.depth {
|
||||
@@ -106,12 +108,9 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec<
|
||||
}
|
||||
}
|
||||
let big = doc.size.width > 30_000 || doc.size.height > 30_000;
|
||||
let size_exceeds_limit = !force_psb && !big && crate::psd_export::psd_size_exceeds_limit(doc);
|
||||
let version = crate::psd_export::psd_version(doc, force_psb, size_exceeds_limit);
|
||||
let version = crate::psd_export::psd_version(doc, force_psb);
|
||||
if big && !force_psb {
|
||||
warnings.push("document exceeds 30000 px; written as PSB".into());
|
||||
} else if size_exceeds_limit && !force_psb {
|
||||
warnings.push("estimated encoded size exceeds 2 GB; written as PSB".into());
|
||||
}
|
||||
let header = Header::new(version, doc.size.width, doc.size.height, channels.len().max(1) as u16, psd_depth(sample), PsdMode::Multichannel);
|
||||
let image_data = ImageData::encode(Compression::Rle, &planes, &header)
|
||||
@@ -133,14 +132,16 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec<
|
||||
// Alpha identifiers: one u32 per channel, only while they still match the channels.
|
||||
let keep = |id: u16, data: &[u8]| id != ALPHA_IDENTIFIERS || data.len() == channels.len().saturating_mul(4);
|
||||
let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth);
|
||||
let file = layerless_file(doc, header, image_data, color_mode_data, resources, keep, &mut warnings);
|
||||
let file = layerless_file(doc, header, image_data, color_mode_data, resources, keep, size_limit, &mut warnings);
|
||||
(file, warnings)
|
||||
}
|
||||
|
||||
/// A PSD without layers around `image_data`: `resources` (the mode's own) plus the paths, XMP,
|
||||
/// EXIF and every other imported resource `keep` accepts (except the ones that only describe
|
||||
/// layers and any already in `resources`), and the document-level tagged blocks. Shared by the
|
||||
/// modes that have no layers in Photoshop: Multichannel, Indexed Color and Bitmap.
|
||||
/// modes that have no layers in Photoshop: Multichannel, Indexed Color and Bitmap. A file past
|
||||
/// `size_limit` bytes is written as PSB (see `psd_export::fit_psd_size`); `None` keeps the version.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn layerless_file(
|
||||
doc: &Document,
|
||||
header: Header,
|
||||
@@ -148,6 +149,7 @@ pub(crate) fn layerless_file(
|
||||
color_mode_data: Vec<u8>,
|
||||
mut resources: Vec<ImageResource>,
|
||||
keep: impl Fn(u16, &[u8]) -> bool,
|
||||
size_limit: Option<u64>,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> PsdFile {
|
||||
resources.extend(crate::psd_export::path_resources(doc, warnings));
|
||||
@@ -175,13 +177,14 @@ pub(crate) fn layerless_file(
|
||||
}
|
||||
resources.push(version_info_resource(true));
|
||||
// Document-level tagged blocks (patterns, notes, ...) are valid without a layer section; a
|
||||
// kept layer block would describe layers the document doesn't have.
|
||||
// kept layer block would describe layers the document doesn't have. Their signatures follow
|
||||
// the version the file is written as, known once its size is.
|
||||
let global_blocks: Vec<TaggedBlock> = crate::annotations_map::export_blocks(doc, crate::pattern_map::export_global_blocks(doc))
|
||||
.into_iter()
|
||||
.filter(|(_, key, _)| !matches!(key, b"Layr" | b"Lr16" | b"Lr32"))
|
||||
.map(|(sig, key, data)| crate::psd_export::global_block(crate::psd_export::block_signature(sig, key, header.version), key, &data))
|
||||
.map(|(sig, key, data)| crate::psd_export::global_block(sig, key, &data))
|
||||
.collect();
|
||||
PsdFile {
|
||||
let mut file = PsdFile {
|
||||
header,
|
||||
color_mode_data,
|
||||
resources,
|
||||
@@ -191,5 +194,11 @@ pub(crate) fn layerless_file(
|
||||
global_blocks,
|
||||
layer_mask_trailing: Vec::new(),
|
||||
image_data,
|
||||
write_as_psb: false,
|
||||
};
|
||||
if let Some(limit) = size_limit {
|
||||
warnings.extend(crate::psd_export::fit_psd_size(&mut file, limit));
|
||||
}
|
||||
crate::psd_export::sign_global_blocks(&mut file);
|
||||
file
|
||||
}
|
||||
|
||||
@@ -414,7 +414,7 @@ pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result<Im
|
||||
}
|
||||
let xml = zip.read_by_name("stack.xml", MAX_STACK_XML).map_err(|e| invalid(e.to_string()))?;
|
||||
let xml = std::str::from_utf8(&xml).map_err(|_| invalid("stack.xml is not UTF-8"))?;
|
||||
let tree = roxmltree::Document::parse(xml).map_err(|e| invalid(format!("stack.xml: {e}")))?;
|
||||
let tree = crate::xml::parse(xml, roxmltree::ParsingOptions::default()).map_err(|e| invalid(format!("stack.xml: {e}")))?;
|
||||
let image = tree.root_element();
|
||||
if image.tag_name().name() != "image" {
|
||||
return Err(invalid("stack.xml has no <image> root"));
|
||||
|
||||
@@ -195,6 +195,16 @@ fn archive(stack: &str, images: &[(&str, Vec<u8>)]) -> Vec<u8> {
|
||||
z.finish().unwrap()
|
||||
}
|
||||
|
||||
/// A stack.xml nested 20 000 deep overflowed the XML parser's recursion (an abort nothing can
|
||||
/// catch); it is an error now. The small stack makes a regression abort instead of passing.
|
||||
#[test]
|
||||
fn deeply_nested_stack_xml_is_an_error_not_a_stack_overflow() {
|
||||
let stack = format!("<image w=\"4\" h=\"4\">{}{}</image>", "<stack>".repeat(20_000), "</stack>".repeat(20_000));
|
||||
let bytes = archive(&stack, &[]);
|
||||
let e = std::thread::Builder::new().stack_size(512 * 1024).spawn(move || crate::import("deep.ora", &bytes).err()).unwrap().join().unwrap();
|
||||
assert!(matches!(&e, Some(IoError::Ora(m)) if m.contains("nested")), "{e:?}");
|
||||
}
|
||||
|
||||
fn solid_png(w: u32, h: u32, rgba: [u8; 4]) -> Vec<u8> {
|
||||
let data = rgba.iter().copied().cycle().take((w * h * 4) as usize).collect();
|
||||
codecs::encode(&Image::from_raw(w, h, ChannelLayout::Rgba, CSample::U8, data).unwrap(), Format::Png, &Default::default()).unwrap()
|
||||
|
||||
@@ -21,7 +21,9 @@ const CHANNEL_RESOURCES: [u16; 4] = [1006, 1045, 1053, crate::channel_map::DISPL
|
||||
/// opaque, or a Bitmap document; None for any other document (it is written like the others). A
|
||||
/// document with alpha channels or a Quick Mask is written like the others too: the importer does
|
||||
/// not read the extra channels of these modes back, so they would be lost.
|
||||
pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> Option<(PsdFile, Vec<String>)> {
|
||||
/// A file past `size_limit` bytes is written as PSB (see `psd_export::fit_psd_size`); `None` keeps
|
||||
/// the version whatever the size.
|
||||
pub(crate) fn document_to_psd(doc: &Document, force_psb: bool, size_limit: Option<u64>) -> Option<(PsdFile, Vec<String>)> {
|
||||
if !doc.channels.is_empty() || doc.quick_mask.is_some() {
|
||||
return None;
|
||||
}
|
||||
@@ -38,9 +40,7 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> Option<(PsdFil
|
||||
if !flattens_losslessly(doc) {
|
||||
warnings.push(format!("{:?} documents have no layers in Photoshop: written flattened", doc.mode));
|
||||
}
|
||||
let big = doc.size.width > 30_000 || doc.size.height > 30_000;
|
||||
let size_exceeds_limit = !force_psb && !big && crate::psd_export::psd_size_exceeds_limit(doc);
|
||||
let version = crate::psd_export::psd_version(doc, force_psb, size_exceeds_limit);
|
||||
let version = crate::psd_export::psd_version(doc, force_psb);
|
||||
let depth = if mode == PsdMode::Bitmap { 1 } else { 8 };
|
||||
let header = Header::new(version, doc.size.width, doc.size.height, 1, depth, mode);
|
||||
let image_data = ImageData::encode(Compression::Rle, &plane, &header)
|
||||
@@ -65,7 +65,7 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> Option<(PsdFil
|
||||
}
|
||||
// An imported transparency index that no longer applies is not copied either.
|
||||
let keep = |id: u16, _: &[u8]| !CHANNEL_RESOURCES.contains(&id) && id != TRANSPARENCY_INDEX && id != COLOR_TABLE_COUNT;
|
||||
let file = crate::multichannel_map::layerless_file(doc, header, image_data, color_mode_data, resources, keep, &mut warnings);
|
||||
let file = crate::multichannel_map::layerless_file(doc, header, image_data, color_mode_data, resources, keep, size_limit, &mut warnings);
|
||||
Some((file, warnings))
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn round_trip(doc: &Document) -> (PsdFile, Document) {
|
||||
let (file, _) = document_to_psd(doc, false).unwrap();
|
||||
let (file, _) = document_to_psd(doc, false, None).unwrap();
|
||||
let bytes = file.to_bytes().unwrap();
|
||||
let file = PsdFile::from_bytes(&bytes).unwrap();
|
||||
let back = crate::import("test.psd", &bytes).unwrap().document;
|
||||
@@ -214,17 +214,17 @@ mod tests {
|
||||
assert_eq!(back.color_table, doc.color_table);
|
||||
for count in [0, 257] {
|
||||
doc.color_table = Some(ColorTable { colors: vec![[0, 0, 0]; count], transparent: None });
|
||||
assert!(document_to_psd(&doc, false).is_none(), "{count}");
|
||||
assert!(document_to_psd(&doc, false, None).is_none(), "{count}");
|
||||
}
|
||||
doc.color_table = None;
|
||||
assert!(document_to_psd(&doc, false).is_none());
|
||||
assert!(document_to_psd(&doc, false, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_import_bounds_malformed_count_and_transparency_resources() {
|
||||
let mut doc = document(1, 1, SampleType::U8, ColorMode::Indexed, &[0.0, 0.0, 0.0, 1.0]);
|
||||
doc.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 255, 255]], transparent: None });
|
||||
let (original, _) = document_to_psd(&doc, false).unwrap();
|
||||
let (original, _) = document_to_psd(&doc, false, None).unwrap();
|
||||
for (count, expected) in [(vec![0, 0], 1), (vec![1, 1], 256), (vec![255, 255], 256), (vec![0], 256)] {
|
||||
let mut file = original.clone();
|
||||
file.resources.retain(|r| r.id != COLOR_TABLE_COUNT);
|
||||
|
||||
+23
-4
@@ -93,11 +93,30 @@ pub fn deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) ->
|
||||
let bps = s.bytes();
|
||||
let n = bytes.len() / (ch * bps).max(1);
|
||||
if s == SampleType::U8 {
|
||||
// Fast path: one plane per thread, no per-sample copies.
|
||||
return par_map((0..ch).collect(), |c| {
|
||||
let it = bytes.iter().skip(c).step_by(ch).take(n);
|
||||
if invert[c] { it.map(|v| 255 - v).collect() } else { it.copied().collect() }
|
||||
// Fast path: one band of pixels per core, written straight into every plane.
|
||||
let mut planes = vec![vec![0u8; n]; ch];
|
||||
let bs = bands(n);
|
||||
let mut slots: Vec<Vec<&mut [u8]>> = bs.iter().map(|_| Vec::with_capacity(ch)).collect();
|
||||
for plane in &mut planes {
|
||||
let mut rest = plane.as_mut_slice();
|
||||
for (slot, band) in slots.iter_mut().zip(&bs) {
|
||||
let Some((part, tail)) = rest.split_at_mut_checked(band.len()) else { break };
|
||||
slot.push(part);
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
par_map(bs.into_iter().zip(slots).collect(), |(band, mut outs)| {
|
||||
let src = bytes.get(band.start * ch..band.end * ch).unwrap_or(&[]);
|
||||
for (c, out) in outs.iter_mut().enumerate() {
|
||||
let px = src.chunks_exact(ch).filter_map(|p| p.get(c).copied());
|
||||
if invert.get(c).copied().unwrap_or(false) {
|
||||
out.iter_mut().zip(px).for_each(|(o, v)| *o = 255 - v);
|
||||
} else {
|
||||
out.iter_mut().zip(px).for_each(|(o, v)| *o = v);
|
||||
}
|
||||
}
|
||||
});
|
||||
return planes;
|
||||
}
|
||||
// One band of rows per core, appended in band order into pre-grown planes.
|
||||
let row = ch * bps;
|
||||
|
||||
+28
-13
@@ -22,13 +22,11 @@ mod resources;
|
||||
mod smart;
|
||||
|
||||
pub(crate) use document::document_to_psd_interruptible;
|
||||
#[cfg(test)]
|
||||
use document::document_to_psd_sized;
|
||||
pub use document::{document_to_psd, document_to_psd_with};
|
||||
use document::{document_to_psd_nested, mask_parameters};
|
||||
#[cfg(test)]
|
||||
pub(crate) use estimate::estimate_psd_size;
|
||||
pub(crate) use estimate::{block_signature, psd_size_exceeds_limit, psd_version};
|
||||
#[cfg(test)]
|
||||
use estimate::{estimate_encoded_size, exceeds_psd_size_limit};
|
||||
pub(crate) use estimate::{fit_psd_size, psd_version, sign_global_blocks};
|
||||
pub(crate) use resources::{global_block, guides_resource, keep_imported_resource, legacy_name, pascal_names_resource, path_resources, unicode_names_resource};
|
||||
use smart::{LINK_KEYS, SmartOut, Source, contents_uuid, effects_unchanged, masked, size_from_layer, smart_keys, upsert};
|
||||
|
||||
@@ -37,24 +35,25 @@ use smart::{LINK_KEYS, SmartOut, Source, contents_uuid, effects_unchanged, maske
|
||||
/// A raster layer never writes them, nor adjustment blocks.
|
||||
const RASTER_STALE_KEYS: [&[u8; 4]; 8] = [b"SoCo", b"GdFl", b"PtFl", b"SoLd", b"PlLd", b"SoLE", b"TySh", b"vscg"];
|
||||
|
||||
/// The largest file written as PSD: past it, the document is saved as PSB (Large Document Format),
|
||||
/// as Photoshop does.
|
||||
const PSD_MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024;
|
||||
/// Room kept under [`PSD_MAX_FILE_SIZE`] for what the save adds after the conversion (the image
|
||||
/// preview resource, a few KB).
|
||||
const PSD_SAVE_HEADROOM: u64 = 1024 * 1024;
|
||||
/// Most bytes of one layer's interleaved samples the exporter gathers. The interleaved bytes and
|
||||
/// the channel planes made from them (or the band parts and the joined buffer) are alive together,
|
||||
/// so the peak is about twice this; it is also what fits in a file of at most [`PSD_MAX_FILE_SIZE`].
|
||||
const MAX_LAYER_GATHER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 1 << 30 } else { 256 << 20 };
|
||||
const PSD_ESTIMATE_FIXED_OVERHEAD: u64 = 1024 * 1024;
|
||||
const PSD_ESTIMATE_LAYER_OVERHEAD: u64 = 64 * 1024;
|
||||
|
||||
/// Options for [`document_to_psd_with`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PsdExportOptions {
|
||||
/// Write PSB even when the document fits PSD limits.
|
||||
pub force_psb: bool,
|
||||
/// Switch to PSB when the estimated encoded size exceeds PSD's 2 GB limit. The estimate
|
||||
/// counts every layer plane uncompressed, so a document with many canvas-sized layers can
|
||||
/// pass it while its real file is far smaller. A writer that can measure the real size
|
||||
/// instead turns this off: the layered TIFF writes PSD-style layer data and falls back to
|
||||
/// PSB only when a length really overflows 32 bits.
|
||||
/// Switch to PSB when the encoded file exceeds PSD's 2 GB limit (`fit_psd_size`). A writer
|
||||
/// that embeds the layer data elsewhere turns this off: the layered TIFF writes PSD-style
|
||||
/// layer data and falls back to PSB only when a length really overflows 32 bits.
|
||||
pub size_limit_psb: bool,
|
||||
/// Matte the merged image against white where it is translucent, as Photoshop does
|
||||
/// (`false` keeps straight colour under the alpha: for containers that store the composite
|
||||
@@ -543,7 +542,23 @@ impl Ex {
|
||||
let masked = l.vector_mask.is_some();
|
||||
raw.retain(|(k, _)| !(RASTER_STALE_KEYS.contains(&k) || adjust_map::ADJUSTMENT_KEYS.contains(&k) || k == b"CgEd" || (k == b"vstk" && masked)));
|
||||
}
|
||||
LayerContent::Group(_) => {}
|
||||
// A Frame's stroke (`vstk`), kept byte-identical while it still decodes to the model.
|
||||
LayerContent::Group(g) => {
|
||||
let pos = raw.iter().position(|(k, _)| k == b"vstk");
|
||||
let old = pos.and_then(|i| raw.get(i)).and_then(|(_, d)| crate::vector_map::parse_vstk(d, self.dpi));
|
||||
// One we can't decode stays as it was unless the model has a stroke to write.
|
||||
let stale = match &old {
|
||||
Some(v) => v.stroke != g.frame_stroke,
|
||||
None => g.frame_stroke.is_some(),
|
||||
};
|
||||
if stale {
|
||||
let data = crate::vector_map::vstk_bytes(g.frame_stroke.as_ref(), old.is_none_or(|v| v.fill_enabled), self.dpi);
|
||||
match pos.and_then(|i| raw.get_mut(i)) {
|
||||
Some(e) => e.1 = data,
|
||||
None => raw.push((*b"vstk", data)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Effects reference point: written from the field (in place, keeping block order).
|
||||
match l.effects.reference {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::estimate::{MERGED_RENDERED, MERGED_WEIGHT, block_signature, psd_size_exceeds_limit, psd_version};
|
||||
use super::estimate::{MERGED_RENDERED, MERGED_WEIGHT, fit_psd_size, psd_version, sign_global_blocks};
|
||||
use super::merged::{cmyk_of, merged_planes, placeholder_planes};
|
||||
use super::resources::{global_block, guides_resource, keep_imported_resource, legacy_name, pascal_names_resource, path_resources, unicode_names_resource};
|
||||
use super::smart::{SmartOut, link_group_resource};
|
||||
@@ -25,10 +25,17 @@ pub(crate) fn document_to_psd_interruptible(doc: &Document, opts: &PsdExportOpti
|
||||
|
||||
/// [`document_to_psd_with`] for a document embedded `depth` smart objects deep.
|
||||
pub(super) fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, ctl: &Interrupt) -> (PsdFile, Vec<String>) {
|
||||
document_to_psd_sized(doc, opts, depth, ctl, PSD_MAX_FILE_SIZE)
|
||||
}
|
||||
|
||||
/// [`document_to_psd_nested`] with the largest file kept as PSD (lowered by tests).
|
||||
pub(super) fn document_to_psd_sized(doc: &Document, opts: &PsdExportOptions, depth: u32, ctl: &Interrupt, size_limit: u64) -> (PsdFile, Vec<String>) {
|
||||
// Without `size_limit_psb` the encoded size never changes the version, in every mode.
|
||||
let layerless_limit = opts.size_limit_psb.then_some(size_limit);
|
||||
if doc.mode == ColorMode::Multichannel {
|
||||
return crate::multichannel_map::document_to_psd(doc, opts.force_psb);
|
||||
return crate::multichannel_map::document_to_psd(doc, opts.force_psb, layerless_limit);
|
||||
}
|
||||
if let Some(file) = crate::palette_map::document_to_psd(doc, opts.force_psb) {
|
||||
if let Some(file) = crate::palette_map::document_to_psd(doc, opts.force_psb, layerless_limit) {
|
||||
return file;
|
||||
}
|
||||
let fmt = doc.pixel_format();
|
||||
@@ -43,8 +50,7 @@ pub(super) fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, de
|
||||
let sample = fmt.sample;
|
||||
let cc = fmt.mode.color_channels();
|
||||
let big = doc.size.width > 30_000 || doc.size.height > 30_000;
|
||||
let size_exceeds_limit = opts.size_limit_psb && !opts.force_psb && !big && psd_size_exceeds_limit(doc);
|
||||
let version = psd_version(doc, opts.force_psb, size_exceeds_limit);
|
||||
let version = psd_version(doc, opts.force_psb);
|
||||
let mut ex = Ex {
|
||||
fmt,
|
||||
dpi: doc.resolution_dpi,
|
||||
@@ -70,8 +76,6 @@ pub(super) fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, de
|
||||
};
|
||||
if big && !opts.force_psb {
|
||||
ex.warnings.push("document exceeds 30000 px; written as PSB".into());
|
||||
} else if size_exceeds_limit && !opts.force_psb {
|
||||
ex.warnings.push("estimated encoded size exceeds 2 GB; written as PSB".into());
|
||||
}
|
||||
if fmt.mode != doc.mode && duotone_inks.is_none() {
|
||||
ex.warnings.push(format!("{:?} document written as {:?}", doc.mode, fmt.mode));
|
||||
@@ -281,7 +285,8 @@ pub(super) fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, de
|
||||
if !ex.records.is_empty() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr") {
|
||||
continue;
|
||||
}
|
||||
let block = global_block(block_signature(sig, key, version), key, &data);
|
||||
// The signature follows the final version (see `sign_global_blocks`).
|
||||
let block = global_block(sig, key, &data);
|
||||
// A kept merged-transparency marker describes the opened file's composite. Over an opaque
|
||||
// one it would make the first alpha channel read back as the transparency, and vanish.
|
||||
if !has_alpha && block.is_merged_transparency_marker() {
|
||||
@@ -362,7 +367,7 @@ pub(super) fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, de
|
||||
Some(raw) => raw,
|
||||
None => photocraft_psd::hdr::color_mode_data_for_depth(header.depth),
|
||||
};
|
||||
let file = PsdFile {
|
||||
let mut file = PsdFile {
|
||||
header,
|
||||
color_mode_data,
|
||||
resources,
|
||||
@@ -372,7 +377,12 @@ pub(super) fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, de
|
||||
global_blocks,
|
||||
layer_mask_trailing: Vec::new(),
|
||||
image_data,
|
||||
write_as_psb: false,
|
||||
};
|
||||
if opts.size_limit_psb {
|
||||
ex.warnings.extend(fit_psd_size(&mut file, size_limit));
|
||||
}
|
||||
sign_global_blocks(&mut file);
|
||||
(file, ex.warnings)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,109 +8,6 @@ pub(super) const MERGED_WEIGHT: f32 = 2.0;
|
||||
/// rest is compressing its planes.
|
||||
pub(super) const MERGED_RENDERED: f32 = 0.8;
|
||||
|
||||
/// Adds headroom for compressed channel streams and their per-row length tables.
|
||||
pub(super) fn estimate_encoded_size(raw_bytes: u64, rows: u64, structural_bytes: u64) -> Option<u64> {
|
||||
raw_bytes.checked_add(raw_bytes.checked_add(63)?.checked_div(64)?)?.checked_add(rows.checked_mul(8)?)?.checked_add(structural_bytes)
|
||||
}
|
||||
|
||||
fn add_plane_estimate(raw_bytes: &mut u64, rows: &mut u64, width: u64, height: u64, channels: u64, bytes_per_sample: u64) -> Option<()> {
|
||||
let plane_bytes = width.checked_mul(height)?.checked_mul(channels)?.checked_mul(bytes_per_sample)?;
|
||||
let plane_rows = height.checked_mul(channels)?;
|
||||
*raw_bytes = raw_bytes.checked_add(plane_bytes)?;
|
||||
*rows = rows.checked_add(plane_rows)?;
|
||||
Some(())
|
||||
}
|
||||
|
||||
pub(super) fn exceeds_psd_size_limit(estimated_size: Option<u64>) -> bool {
|
||||
estimated_size.is_none_or(|size| size > PSD_MAX_FILE_SIZE)
|
||||
}
|
||||
|
||||
fn surface_dimensions(surface: &Surface) -> Option<(u64, u64)> {
|
||||
let bounds = surface.content_bounds();
|
||||
Some((u64::from(bounds.width()), u64::from(bounds.height())))
|
||||
}
|
||||
|
||||
pub(crate) fn estimate_psd_size(doc: &Document) -> Option<u64> {
|
||||
let mut raw_bytes = 0;
|
||||
let mut rows = 0;
|
||||
let mut structural_bytes = PSD_ESTIMATE_FIXED_OVERHEAD;
|
||||
|
||||
let width = u64::from(doc.size.width);
|
||||
let height = u64::from(doc.size.height);
|
||||
|
||||
if doc.mode == ColorMode::Multichannel {
|
||||
let channels = u64::try_from(doc.channels.len().clamp(1, 56)).ok()?;
|
||||
let sample_bytes = u64::try_from(if doc.depth == SampleType::F32 { SampleType::U16.bytes() } else { doc.depth.bytes() }).ok()?;
|
||||
add_plane_estimate(&mut raw_bytes, &mut rows, width, height, channels, sample_bytes)?;
|
||||
} else {
|
||||
let format = doc.pixel_format();
|
||||
let color_channels = u64::try_from(format.mode.color_channels()).ok()?;
|
||||
let bytes_per_sample = u64::try_from(format.sample.bytes()).ok()?;
|
||||
let layer_channels = color_channels.checked_add(1)?;
|
||||
|
||||
for (_, _, layer) in doc.walk() {
|
||||
structural_bytes = structural_bytes.checked_add(PSD_ESTIMATE_LAYER_OVERHEAD)?.checked_add(u64::try_from(layer.name.len()).ok()?)?;
|
||||
for (_, data) in &layer.psd_blocks {
|
||||
structural_bytes = structural_bytes.checked_add(u64::try_from(data.len()).ok()?)?;
|
||||
}
|
||||
|
||||
let surface = match &layer.content {
|
||||
LayerContent::Fill(fill) => {
|
||||
let mut fill_width = width;
|
||||
let mut fill_height = height;
|
||||
if let Some(cache) = &layer.fill_cache
|
||||
&& cache.fill == *fill
|
||||
{
|
||||
let (cache_width, cache_height) = surface_dimensions(&cache.surface)?;
|
||||
fill_width = fill_width.max(cache_width);
|
||||
fill_height = fill_height.max(cache_height);
|
||||
}
|
||||
add_plane_estimate(&mut raw_bytes, &mut rows, fill_width, fill_height, layer_channels, bytes_per_sample)?;
|
||||
None
|
||||
}
|
||||
_ => layer.surface(),
|
||||
};
|
||||
if let Some(surface) = surface {
|
||||
let (surface_width, surface_height) = surface_dimensions(surface)?;
|
||||
add_plane_estimate(&mut raw_bytes, &mut rows, surface_width, surface_height, layer_channels, bytes_per_sample)?;
|
||||
}
|
||||
if let Some(mask) = &layer.mask {
|
||||
let (mask_width, mask_height) = surface_dimensions(&mask.surface)?;
|
||||
add_plane_estimate(&mut raw_bytes, &mut rows, mask_width, mask_height, 1, bytes_per_sample)?;
|
||||
}
|
||||
if let LayerContent::Smart(smart) = &layer.content
|
||||
&& let photocraft_doc::SmartSource::Embedded { bytes, .. } = &smart.source
|
||||
{
|
||||
structural_bytes = structural_bytes.checked_add(u64::try_from(bytes.len()).ok()?)?;
|
||||
}
|
||||
}
|
||||
|
||||
let max_extra_channels = 56usize.saturating_sub(format.mode.color_channels() + 1);
|
||||
let mut extra_channels = doc.channels.len().min(max_extra_channels);
|
||||
if doc.quick_mask.is_some() && extra_channels < max_extra_channels {
|
||||
extra_channels += 1;
|
||||
}
|
||||
let merged_channels = color_channels.checked_add(1)?.checked_add(u64::try_from(extra_channels).ok()?)?;
|
||||
add_plane_estimate(&mut raw_bytes, &mut rows, width, height, merged_channels, bytes_per_sample)?;
|
||||
for channel in doc.channels.iter().take(extra_channels) {
|
||||
structural_bytes = structural_bytes.checked_add(u64::try_from(channel.name.len()).ok()?)?;
|
||||
}
|
||||
}
|
||||
|
||||
structural_bytes = structural_bytes
|
||||
.checked_add(u64::try_from(doc.icc_profile.as_ref().map_or(0, |profile| profile.len())).ok()?)?
|
||||
.checked_add(u64::try_from(doc.metadata.xmp.as_deref().map_or(0, str::len)).ok()?)?
|
||||
.checked_add(u64::try_from(doc.metadata.exif.as_ref().map_or(0, |data| data.len())).ok()?)?;
|
||||
for (_, name, data) in &doc.metadata.psd_resources {
|
||||
structural_bytes = structural_bytes.checked_add(u64::try_from(name.len()).ok()?)?.checked_add(u64::try_from(data.len()).ok()?)?;
|
||||
}
|
||||
for (_, _, data) in &doc.metadata.psd_global_blocks {
|
||||
structural_bytes = structural_bytes.checked_add(u64::try_from(data.len()).ok()?)?;
|
||||
}
|
||||
|
||||
estimate_encoded_size(raw_bytes, rows, structural_bytes)
|
||||
}
|
||||
|
||||
/// The signature a kept tagged block is written with. `8B64` exists only in PSB files: a PSD
|
||||
/// with an `8B64` block (kept from a PSB source, e.g. its global `FMsk` or `cinf`) is refused by
|
||||
/// Photoshop as "not compatible with this version" (measured in Photoshop 27.11). The other way
|
||||
@@ -125,11 +22,48 @@ pub(crate) fn block_signature(sig: [u8; 4], key: [u8; 4], version: Version) -> [
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn psd_version(doc: &Document, force_psb: bool, size_exceeds_limit: bool) -> Version {
|
||||
/// The version a document is encoded as: PSB when asked for or past PSD's 30000 px; otherwise
|
||||
/// PSD, which [`fit_psd_size`] turns into PSB once the encoded file proves too large.
|
||||
pub(crate) fn psd_version(doc: &Document, force_psb: bool) -> Version {
|
||||
let exceeds_dimensions = doc.size.width > 30_000 || doc.size.height > 30_000;
|
||||
if force_psb || exceeds_dimensions || size_exceeds_limit { Version::Psb } else { Version::Psd }
|
||||
if force_psb || exceeds_dimensions { Version::Psb } else { Version::Psd }
|
||||
}
|
||||
|
||||
pub(crate) fn psd_size_exceeds_limit(doc: &Document) -> bool {
|
||||
exceeds_psd_size_limit(estimate_psd_size(doc))
|
||||
/// Keeps an encoded PSD whose file fits `limit` (less [`PSD_SAVE_HEADROOM`]) and turns a larger
|
||||
/// one into PSB (#212, #5285). The decision is made on the exact length of the file as it will be
|
||||
/// written, not on the raw pixel size: RLE usually packs the channels far below it. Promoting
|
||||
/// re-encodes nothing (only the RLE row-count tables widen, see [`PsdFile::promote_to_psb`]).
|
||||
/// Returns a warning when the version changed or could not be changed.
|
||||
pub(crate) fn fit_psd_size(file: &mut PsdFile, limit: u64) -> Option<String> {
|
||||
if file.written_version().is_psb() {
|
||||
return None;
|
||||
}
|
||||
// A length that cannot be computed (it overflows) cannot fit either.
|
||||
if file.encoded_len().is_ok_and(|len| len.saturating_add(PSD_SAVE_HEADROOM) <= limit) {
|
||||
return None;
|
||||
}
|
||||
match file.promote_to_psb() {
|
||||
Ok(()) => {
|
||||
// Photoshop pads the layer info to 4 bytes; the padding depends on the version.
|
||||
let padded = file.layer_info.as_ref().is_some_and(|info| info.padding.is_some());
|
||||
if matches!(file.layer_info_placement, LayerInfoPlacement::Section)
|
||||
&& padded
|
||||
&& file.pad_layer_info_to(4).is_err()
|
||||
&& let Some(info) = &mut file.layer_info
|
||||
{
|
||||
info.padding = None;
|
||||
}
|
||||
Some("encoded size exceeds 2 GB; written as PSB".into())
|
||||
}
|
||||
Err(e) => Some(format!("encoded size exceeds 2 GB, but the file could not be written as PSB: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs every global block for the version `file` is written as ([`block_signature`]): run
|
||||
/// after [`fit_psd_size`], which may turn a PSD into a PSB.
|
||||
pub(crate) fn sign_global_blocks(file: &mut PsdFile) {
|
||||
let version = file.written_version();
|
||||
for block in &mut file.global_blocks {
|
||||
block.signature = block_signature(block.signature, block.key, version);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,39 +29,58 @@ pub(super) fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte
|
||||
if ctl.cancelled() {
|
||||
return Err(());
|
||||
}
|
||||
// Retain alpha whenever it differs from opaque at the stored precision.
|
||||
has_alpha |= band.px.iter().any(|p| match sample {
|
||||
SampleType::U8 => q255(p[3]) < 255,
|
||||
SampleType::U16 => (p[3].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16 != u16::MAX,
|
||||
SampleType::F32 => p[3] != 1.0,
|
||||
});
|
||||
translucent |= band.px.iter().any(|p| p[3] < 1.0);
|
||||
let start = (band.rect.y0 - canvas.y0) as usize * w;
|
||||
// Converted and encoded on all cores, then copied into each plane.
|
||||
let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| {
|
||||
// The composite came through the document's CMYK profile: convert back through it too.
|
||||
photocraft_color::convert::with_cmyk_space(space.as_ref(), || {
|
||||
let mut out: Vec<Vec<u8>> = vec![Vec::with_capacity(range.len() * bps); cc + 1];
|
||||
let mut v = [0.0f32; 5];
|
||||
for p in &band.px[range.clone()] {
|
||||
photocraft_raster::from_rgba_into(fmt, *p, &mut v);
|
||||
for c in 0..=cc {
|
||||
// Matte against white like Photoshop (see `pixels::matte`).
|
||||
let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] };
|
||||
let x = if cmyk && c < cc { 1.0 - m } else { m };
|
||||
// 16-bit Lab a*/b* use Photoshop's 0..65280 scale (`pixels::lab16_chroma`).
|
||||
let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x };
|
||||
encode_be(x, sample, &mut out[c]);
|
||||
}
|
||||
}
|
||||
(range.start, out)
|
||||
let ranges = crate::pixels::bands(band.px.len());
|
||||
let mut slots: Vec<Vec<&mut [u8]>> = ranges.iter().map(|_| Vec::with_capacity(cc + 1)).collect();
|
||||
for chunk in planes.chunks_mut(plane.max(1)).take(cc + 1) {
|
||||
let Some(mut rest) = chunk.get_mut(start * bps..(start + band.px.len()) * bps) else { continue };
|
||||
for (slot, range) in slots.iter_mut().zip(&ranges) {
|
||||
let Some((part, tail)) = rest.split_at_mut_checked(range.len() * bps) else { break };
|
||||
slot.push(part);
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
// Converted and encoded on all cores. The composite came through the document's CMYK
|
||||
// profile: convert back through it too.
|
||||
let flags = crate::pixels::par_map(ranges.into_iter().zip(slots).collect(), |(range, mut out)| {
|
||||
let px = band.px.get(range).unwrap_or(&[]);
|
||||
let k = Encode { fmt, cc, matte, cmyk, lab16, white: &white };
|
||||
photocraft_color::convert::with_cmyk_space(space.as_ref(), || match sample {
|
||||
SampleType::U8 => k.run(
|
||||
px,
|
||||
&mut out,
|
||||
|a| q255(a) < 255,
|
||||
|x, d, i| {
|
||||
if let Some(b) = d.get_mut(i) {
|
||||
*b = (x.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
},
|
||||
),
|
||||
SampleType::U16 => k.run(
|
||||
px,
|
||||
&mut out,
|
||||
|a| (a.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16 != u16::MAX,
|
||||
|x, d, i| {
|
||||
if let Some(b) = d.get_mut(i * 2..i * 2 + 2) {
|
||||
b.copy_from_slice(&((x.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).to_be_bytes());
|
||||
}
|
||||
},
|
||||
),
|
||||
SampleType::F32 => k.run(
|
||||
px,
|
||||
&mut out,
|
||||
|a| a != 1.0,
|
||||
|x, d, i| {
|
||||
if let Some(b) = d.get_mut(i * 4..i * 4 + 4) {
|
||||
b.copy_from_slice(&x.to_be_bytes());
|
||||
}
|
||||
},
|
||||
),
|
||||
})
|
||||
});
|
||||
for (at, part) in parts {
|
||||
for (c, src) in part.iter().enumerate() {
|
||||
let o = c * plane + (start + at) * bps;
|
||||
planes[o..o + src.len()].copy_from_slice(src);
|
||||
}
|
||||
for (a, t) in flags {
|
||||
has_alpha |= a;
|
||||
translucent |= t;
|
||||
}
|
||||
ctl.progress((band.rect.y1 - canvas.y0) as f32 / rows);
|
||||
Ok(())
|
||||
@@ -69,6 +88,38 @@ pub(super) fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte
|
||||
(planes, has_alpha, translucent)
|
||||
}
|
||||
|
||||
struct Encode<'a> {
|
||||
fmt: &'a PixelFormat,
|
||||
cc: usize,
|
||||
matte: bool,
|
||||
cmyk: bool,
|
||||
lab16: bool,
|
||||
white: &'a [f32],
|
||||
}
|
||||
|
||||
impl Encode<'_> {
|
||||
fn run(&self, px: &[[f32; 4]], out: &mut [&mut [u8]], stored_translucent: impl Fn(f32) -> bool, put: impl Fn(f32, &mut [u8], usize)) -> (bool, bool) {
|
||||
let Self { fmt, cc, matte, cmyk, lab16, white } = *self;
|
||||
let (mut has_alpha, mut translucent) = (false, false);
|
||||
let mut v = [0.0f32; 5];
|
||||
for (i, p) in px.iter().enumerate() {
|
||||
// Retain alpha whenever it differs from opaque at the stored precision.
|
||||
has_alpha |= stored_translucent(p[3]);
|
||||
translucent |= p[3] < 1.0;
|
||||
photocraft_raster::from_rgba_into(fmt, *p, &mut v);
|
||||
for (c, dst) in out.iter_mut().enumerate() {
|
||||
// Matte against white like Photoshop (see `pixels::matte`).
|
||||
let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white.get(c).copied().unwrap_or(1.0)) } else { v[c] };
|
||||
let x = if cmyk && c < cc { 1.0 - m } else { m };
|
||||
// 16-bit Lab a*/b* use Photoshop's 0..65280 scale (`pixels::lab16_chroma`).
|
||||
let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x };
|
||||
put(x, dst, i);
|
||||
}
|
||||
}
|
||||
(has_alpha, translucent)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`merged_planes`] of a CMYK document whose layers are mixed on their ink values
|
||||
/// (`photocraft_compose::cmyk`), so opaque pixels keep their exact inks; `None` for documents
|
||||
/// that composite in RGB. Reports progress and stops on cancel like [`merged_planes`].
|
||||
@@ -141,3 +192,80 @@ pub(super) fn placeholder_planes(doc: &Document, fmt: &PixelFormat) -> Vec<u8> {
|
||||
}
|
||||
planes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn previous_merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool) -> (Vec<u8>, bool, bool) {
|
||||
if cmyk && let Some(planes) = merged_ink_planes(doc, fmt.sample, matte, &Interrupt::NONE) {
|
||||
return planes;
|
||||
}
|
||||
let sample = fmt.sample;
|
||||
let cc = fmt.mode.color_channels();
|
||||
let bps = sample.bytes();
|
||||
let plane = doc.size.area() as usize * bps;
|
||||
let mut planes = vec![0u8; plane * (cc + 1)];
|
||||
let (mut has_alpha, mut translucent) = (false, false);
|
||||
let white = photocraft_raster::from_rgba(fmt, [1.0, 1.0, 1.0, 1.0]);
|
||||
let canvas = doc.bounds();
|
||||
let w = canvas.width() as usize;
|
||||
let space = photocraft_compose::cmyk_space(doc);
|
||||
let lab16 = fmt.mode == ColorMode::Lab && sample == SampleType::U16;
|
||||
let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> {
|
||||
has_alpha |= band.px.iter().any(|p| match sample {
|
||||
SampleType::U8 => q255(p[3]) < 255,
|
||||
SampleType::U16 => (p[3].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16 != u16::MAX,
|
||||
SampleType::F32 => p[3] != 1.0,
|
||||
});
|
||||
translucent |= band.px.iter().any(|p| p[3] < 1.0);
|
||||
let start = (band.rect.y0 - canvas.y0) as usize * w;
|
||||
photocraft_color::convert::with_cmyk_space(space.as_ref(), || {
|
||||
let mut v = [0.0f32; 5];
|
||||
for (i, p) in band.px.iter().enumerate() {
|
||||
photocraft_raster::from_rgba_into(fmt, *p, &mut v);
|
||||
for c in 0..=cc {
|
||||
let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] };
|
||||
let x = if cmyk && c < cc { 1.0 - m } else { m };
|
||||
let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x };
|
||||
let mut b = Vec::new();
|
||||
encode_be(x, sample, &mut b);
|
||||
let o = c * plane + (start + i) * bps;
|
||||
planes[o..o + bps].copy_from_slice(&b);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
});
|
||||
(planes, has_alpha, translucent)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_planes_match_the_per_band_reference() {
|
||||
let size = photocraft_geom::Size::new(37, 23);
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Lab, ColorMode::Cmyk] {
|
||||
for sample in SampleType::ALL {
|
||||
for alpha_floor in [0.0f32, 0.999_9, 1.0] {
|
||||
let mut doc = Document::new("merged", size, mode, sample);
|
||||
let fmt = doc.pixel_format();
|
||||
let mut s = Surface::new(fmt);
|
||||
for y in 0..23 {
|
||||
for x in 0..37 {
|
||||
let a = (alpha_floor + (1.0 - alpha_floor) * ((x * 7 + y * 3) % 11) as f32 / 10.0).min(1.0);
|
||||
let rgba = [x as f32 / 36.0, y as f32 / 22.0, ((x + y) % 5) as f32 / 4.0, a];
|
||||
let mut v = [0.0f32; 5];
|
||||
let n = photocraft_raster::from_rgba_into(&fmt, rgba, &mut v);
|
||||
s.write_pixel(x, y, &v[..n]);
|
||||
}
|
||||
}
|
||||
doc.layers.push(Layer::new("pixels", LayerContent::Raster(s)));
|
||||
for matte in [false, true] {
|
||||
let got = merged_planes(&doc, &fmt, cmyk_of(&fmt), matte, &Interrupt::NONE);
|
||||
let want = previous_merged_planes(&doc, &fmt, cmyk_of(&fmt), matte);
|
||||
assert_eq!(got, want, "{mode:?} {sample:?} alpha >= {alpha_floor} matte {matte}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,33 +337,161 @@ fn light_adjustment_is_kept_verbatim_and_warned() {
|
||||
assert!(!crate::adjust_map::is_extended_brit(Some(&plain[1].1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_limit_boundary_keeps_limit_and_switches_above_it() {
|
||||
assert!(!exceeds_psd_size_limit(Some(PSD_MAX_FILE_SIZE)));
|
||||
assert!(exceeds_psd_size_limit(Some(PSD_MAX_FILE_SIZE + 1)));
|
||||
assert!(exceeds_psd_size_limit(None));
|
||||
/// A 1024² RGB document with one full-canvas layer: flat colour (packs to a few KB) or noise
|
||||
/// (RLE can't pack it). Its raw planes (layer RGBA + merged RGB) are 7 MiB either way.
|
||||
fn sized_document(noise: bool, depth: SampleType) -> Document {
|
||||
const SIDE: u32 = 1024;
|
||||
let mut doc = Document::new("size limit", photocraft_geom::Size::new(SIDE, SIDE), ColorMode::Rgb, depth);
|
||||
let mut layer = Layer::raster("Layer 1", doc.pixel_format());
|
||||
let rect = photocraft_geom::Rect::new(0, 0, SIDE as i32, SIDE as i32);
|
||||
let surface = layer.surface_mut().unwrap();
|
||||
if noise {
|
||||
let mut state = 0x2545_f491_u32;
|
||||
let data: Vec<f32> = (0..SIDE * SIDE * 4)
|
||||
.map(|i| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
if i % 4 == 3 { 1.0 } else { (state >> 24) as f32 / 255.0 }
|
||||
})
|
||||
.collect();
|
||||
surface.write_region(rect, &data);
|
||||
} else {
|
||||
surface.fill_rect(rect, &[0.2, 0.4, 0.6, 1.0]);
|
||||
}
|
||||
doc.layers.push(layer);
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_size_estimator_rejects_arithmetic_overflow() {
|
||||
assert_eq!(estimate_encoded_size(u64::MAX, 0, 0), None);
|
||||
assert_eq!(estimate_encoded_size(0, u64::MAX, 0), None);
|
||||
assert_eq!(estimate_encoded_size(0, 0, u64::MAX), Some(u64::MAX));
|
||||
assert!(exceeds_psd_size_limit(estimate_psd_size(&document(u32::MAX, u32::MAX))));
|
||||
const SIZED_RAW_BYTES: u64 = 1024 * 1024 * (4 + 3);
|
||||
/// Under the raw size, so only the encoded size can keep a document PSD.
|
||||
const SIZED_LIMIT: u64 = 4 * 1024 * 1024;
|
||||
const _: () = assert!(SIZED_RAW_BYTES > SIZED_LIMIT);
|
||||
|
||||
fn export_sized(doc: &Document, limit: u64) -> (PsdFile, Vec<String>) {
|
||||
document_to_psd_sized(doc, &PsdExportOptions::default(), 0, &Interrupt::NONE, limit)
|
||||
}
|
||||
|
||||
/// #5285: a document whose raw pixels are over the PSD limit but whose encoded file fits is
|
||||
/// saved as PSD (the raw-size estimate used to make it a PSB).
|
||||
#[test]
|
||||
fn large_encoded_size_selects_psb_without_changing_explicit_choice() {
|
||||
let large = document(30_000, 30_000);
|
||||
assert!(estimate_psd_size(&large).is_some_and(|size| size > PSD_MAX_FILE_SIZE));
|
||||
let large_size_exceeds_limit = psd_size_exceeds_limit(&large);
|
||||
assert_eq!(psd_version(&large, false, large_size_exceeds_limit), Version::Psb);
|
||||
fn a_file_that_fits_after_compression_stays_psd() {
|
||||
let doc = sized_document(false, SampleType::U8);
|
||||
let (file, warnings) = export_sized(&doc, SIZED_LIMIT);
|
||||
assert_eq!(file.written_version(), Version::Psd);
|
||||
assert!(warnings.iter().all(|w| !w.contains("PSB")), "{warnings:?}");
|
||||
let bytes = file.to_bytes().unwrap();
|
||||
assert!(bytes.len() as u64 + PSD_SAVE_HEADROOM <= SIZED_LIMIT, "{} bytes", bytes.len());
|
||||
let (back, _) = crate::psd_to_document(&PsdFile::from_bytes(&bytes).unwrap());
|
||||
assert_eq!(back.layers[0].surface().unwrap().pixel(700, 300), doc.layers[0].surface().unwrap().pixel(700, 300));
|
||||
}
|
||||
|
||||
/// #5285: a document whose encoded file is over the limit becomes a PSB, byte for byte the
|
||||
/// one a forced PSB save writes, and reads back. 8 bits keep the layers in the layer section,
|
||||
/// 16 bits in a global `Lr16` block.
|
||||
#[test]
|
||||
fn a_file_over_the_limit_after_compression_is_psb() {
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
let doc = sized_document(true, depth);
|
||||
let (file, warnings) = export_sized(&doc, SIZED_LIMIT);
|
||||
assert_eq!(file.written_version(), Version::Psb, "{depth:?}");
|
||||
assert!(warnings.iter().any(|w| w == "encoded size exceeds 2 GB; written as PSB"), "{warnings:?}");
|
||||
let bytes = file.to_bytes().unwrap();
|
||||
assert!(bytes.len() as u64 > SIZED_LIMIT);
|
||||
let forced = document_to_psd_with(&doc, &PsdExportOptions { force_psb: true, ..Default::default() }).0;
|
||||
let forced = forced.to_bytes().unwrap();
|
||||
assert!(bytes == forced, "{depth:?}: the promoted PSB differs from a PSB written directly");
|
||||
assert_eq!(PsdFile::from_bytes(&bytes).unwrap(), PsdFile::from_bytes(&forced).unwrap(), "{depth:?}");
|
||||
// The promoted model itself stays a consistent PSD for every reader.
|
||||
assert_eq!(file.header.version, Version::Psd);
|
||||
assert_eq!(crate::psd_to_document(&file).0.layers[0].surface().unwrap().pixel(511, 777), doc.layers[0].surface().unwrap().pixel(511, 777));
|
||||
let (back, _) = crate::psd_to_document(&PsdFile::from_bytes(&bytes).unwrap());
|
||||
for (x, y) in [(0, 0), (1023, 0), (511, 777), (1023, 1023)] {
|
||||
assert_eq!(back.layers[0].surface().unwrap().pixel(x, y), doc.layers[0].surface().unwrap().pixel(x, y), "{depth:?} ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The limit is the exact written length plus the save's headroom: one byte less is a PSB.
|
||||
#[test]
|
||||
fn the_size_limit_is_the_exact_encoded_length() {
|
||||
let doc = sized_document(false, SampleType::U8);
|
||||
let len = export_sized(&doc, u64::MAX).0.to_bytes().unwrap().len() as u64;
|
||||
assert_eq!(export_sized(&doc, len + PSD_SAVE_HEADROOM).0.written_version(), Version::Psd);
|
||||
assert_eq!(export_sized(&doc, len + PSD_SAVE_HEADROOM - 1).0.written_version(), Version::Psb);
|
||||
// The real limit: a small document stays PSD, a forced one is PSB.
|
||||
let small = document(1, 1);
|
||||
let small_size_exceeds_limit = psd_size_exceeds_limit(&small);
|
||||
assert_eq!(psd_version(&small, false, small_size_exceeds_limit), Version::Psd);
|
||||
assert_eq!(psd_version(&small, true, small_size_exceeds_limit), Version::Psb);
|
||||
assert_eq!(document_to_psd(&small).written_version(), Version::Psd);
|
||||
assert_eq!(document_to_psd_with(&small, &PsdExportOptions { force_psb: true, ..Default::default() }).0.written_version(), Version::Psb);
|
||||
assert_eq!(psd_version(&document(30_001, 1), false), Version::Psb);
|
||||
}
|
||||
|
||||
/// A Multichannel document follows the same rule.
|
||||
#[test]
|
||||
fn a_multichannel_file_over_the_limit_is_psb() {
|
||||
let mut doc = document(64, 64);
|
||||
doc.mode = ColorMode::Multichannel;
|
||||
doc.channels.push(photocraft_doc::AlphaChannel::new("Cyan", photocraft_raster::Surface::new(PixelFormat::GRAY8)));
|
||||
let (file, _) = export_sized(&doc, u64::MAX);
|
||||
assert_eq!(file.written_version(), Version::Psd);
|
||||
let len = file.to_bytes().unwrap().len() as u64;
|
||||
let (file, warnings) = export_sized(&doc, len + PSD_SAVE_HEADROOM - 1);
|
||||
assert_eq!(file.written_version(), Version::Psb);
|
||||
assert!(warnings.iter().any(|w| w.contains("written as PSB")), "{warnings:?}");
|
||||
let back = PsdFile::from_bytes(&file.to_bytes().unwrap()).unwrap();
|
||||
assert_eq!(back.header.version, Version::Psb);
|
||||
assert_eq!(back.decode_merged().unwrap(), PsdFile::from_bytes(&export_sized(&doc, u64::MAX).0.to_bytes().unwrap()).unwrap().decode_merged().unwrap());
|
||||
}
|
||||
|
||||
/// Indexed Color and Bitmap (written without layers, #4816), Multichannel and RGB follow the
|
||||
/// encoded-size rule, and a file turned into PSB by its size writes its global blocks with
|
||||
/// 8-byte lengths as `8B64` (#7937), whatever signature they were kept with; a file that fits
|
||||
/// stays a PSD with `8BIM` throughout.
|
||||
#[test]
|
||||
fn every_mode_promoted_by_size_signs_long_blocks_8b64() {
|
||||
let white = photocraft_color::Color::rgba(1.0, 1.0, 1.0, 1.0);
|
||||
let mut docs = Vec::new();
|
||||
for (mode, psd_mode) in [(ColorMode::Indexed, PsdMode::Indexed), (ColorMode::Bitmap, PsdMode::Bitmap), (ColorMode::Rgb, PsdMode::Rgb)] {
|
||||
let mut doc = Document::with_background("modes", photocraft_geom::Size::new(64, 64), ColorMode::Rgb, SampleType::U8, white);
|
||||
doc.mode = mode;
|
||||
doc.color_table = Some(photocraft_doc::ColorTable { colors: vec![[255, 255, 255], [0, 0, 0]], transparent: None });
|
||||
docs.push((doc, psd_mode));
|
||||
}
|
||||
let mut multichannel = document(64, 64);
|
||||
multichannel.mode = ColorMode::Multichannel;
|
||||
multichannel.channels.push(photocraft_doc::AlphaChannel::new("Cyan", photocraft_raster::Surface::new(PixelFormat::GRAY8)));
|
||||
docs.push((multichannel, PsdMode::Multichannel));
|
||||
for (mut doc, psd_mode) in docs {
|
||||
doc.metadata.psd_global_blocks = vec![
|
||||
(*b"8BIM", *b"cinf", std::sync::Arc::new(vec![0, 0, 0, 16, 0, 0, 0, 0])),
|
||||
(*b"8BIM", *b"pths", std::sync::Arc::new(vec![0, 0, 0, 16, 0, 0, 0, 0])),
|
||||
(*b"8B64", *b"FMsk", std::sync::Arc::new(vec![0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 50])),
|
||||
(*b"8BIM", *b"Patt", std::sync::Arc::new(Vec::new())),
|
||||
];
|
||||
let written = |limit| {
|
||||
let (file, warnings) = export_sized(&doc, limit);
|
||||
let bytes = file.to_bytes().unwrap();
|
||||
let back = PsdFile::from_bytes(&bytes).unwrap();
|
||||
let blocks = back.global_blocks.iter().map(|b| (b.signature, b.key)).collect::<Vec<_>>();
|
||||
(bytes.len() as u64, back, blocks, warnings)
|
||||
};
|
||||
let (len, small, blocks, _) = written(u64::MAX);
|
||||
assert_eq!((small.header.version, small.header.color_mode), (Version::Psd, psd_mode), "{psd_mode:?}");
|
||||
assert_eq!(blocks, vec![(*b"8BIM", *b"cinf"), (*b"8BIM", *b"pths"), (*b"8BIM", *b"FMsk"), (*b"8BIM", *b"Patt")], "{psd_mode:?}: a PSD keeps 8BIM");
|
||||
// One byte short of the encoded length: the same document is written as PSB.
|
||||
let (_, big, blocks, warnings) = written(len + PSD_SAVE_HEADROOM - 1);
|
||||
assert_eq!((big.header.version, big.header.color_mode), (Version::Psb, psd_mode), "{psd_mode:?}: {warnings:?}");
|
||||
assert!(warnings.iter().any(|w| w == "encoded size exceeds 2 GB; written as PSB"), "{psd_mode:?}: {warnings:?}");
|
||||
assert_eq!(blocks, vec![(*b"8B64", *b"cinf"), (*b"8B64", *b"pths"), (*b"8B64", *b"FMsk"), (*b"8BIM", *b"Patt")], "{psd_mode:?}: a PSB signs 8B64");
|
||||
assert_eq!(big.decode_merged().unwrap(), small.decode_merged().unwrap(), "{psd_mode:?}");
|
||||
// Without `size_limit_psb` no mode is promoted by its size, the layerless ones included.
|
||||
let opts = PsdExportOptions { size_limit_psb: false, ..PsdExportOptions::default() };
|
||||
let (kept, warnings) = document_to_psd_sized(&doc, &opts, 0, &Interrupt::NONE, len + PSD_SAVE_HEADROOM - 1);
|
||||
assert_eq!((kept.written_version(), kept.header.color_mode), (Version::Psd, psd_mode), "{psd_mode:?}: {warnings:?}");
|
||||
assert!(!warnings.iter().any(|w| w.contains("written as PSB")), "{psd_mode:?}: {warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_psb_blocks_are_written_as_8bim_in_a_psd() {
|
||||
// Photoshop 27.11 refuses a PSD holding `8B64` blocks ("not compatible with this
|
||||
|
||||
@@ -633,7 +633,9 @@ impl Ctx<'_> {
|
||||
let sd = rec.section_divider();
|
||||
let expanded = sd.is_none_or(|s| s.kind != photocraft_psd::SectionType::ClosedFolder);
|
||||
let artboard = crate::comps_map::ARTBOARD_KEYS.iter().find_map(|k| rec.block(k)).and_then(|b| crate::comps_map::parse_artboard(&b.data));
|
||||
let mut l = Layer::new(rec.name(), LayerContent::Group(Group { children, expanded, artboard }));
|
||||
// A Photoshop Frame's stroke: `vstk` on the frame group, in a shape's layout.
|
||||
let frame_stroke = rec.block(b"vstk").and_then(|b| crate::vector_map::parse_vstk(&b.data, self.dpi)).and_then(|v| v.stroke);
|
||||
let mut l = Layer::new(rec.name(), LayerContent::Group(Group { children, expanded, artboard, frame_stroke }));
|
||||
l.psd_blocks = preserved_blocks(rec);
|
||||
self.apply_common(&mut l, rec, sd.and_then(|s| s.blend_mode));
|
||||
self.apply_vector_mask(&mut l, rec);
|
||||
|
||||
@@ -111,7 +111,8 @@ fn enum_out(p: &J, key: &str, default: &str, type_id: &str, table: &[(&str, &str
|
||||
Ok(enumv(type_id, psd))
|
||||
}
|
||||
|
||||
/// `key` rounded to a `long` in `lo..=hi`.
|
||||
/// `key` rounded to a `long` in `lo..=hi` (clamped, and a non-number is the default, as the
|
||||
/// engine reads Extrude, Tiles, Trace Contour, Crystallize and Pointillize).
|
||||
fn pi_in(p: &J, key: &str, default: f64, lo: i32, hi: i32) -> Value {
|
||||
Value::Integer(pf(p, key, default).round().clamp(f64::from(lo), f64::from(hi)) as i32)
|
||||
}
|
||||
|
||||
@@ -1470,7 +1470,8 @@ fn stylize_and_pixelate_filters_open_from_photoshop_shapes() {
|
||||
fltr("filter.stylize.diffuse", json!({"mode": "normal", "seed": 7})),
|
||||
Some(Value::Descriptor(Descriptor::new("Dfs ").with("Md ", enumv("DfsM", "Nrml"))))
|
||||
);
|
||||
// Defaults and out-of-range values are written in Photoshop's ranges.
|
||||
// Defaults and out-of-range values are written as the engine renders them: clamped to
|
||||
// Photoshop's ranges, and a non-number as the default.
|
||||
assert_eq!(
|
||||
fltr("filter.stylize.traceContour", json!({})),
|
||||
Some(Value::Descriptor(Descriptor::new("TrcC").with("Lvl ", Value::Integer(128)).with("Edg ", enumv("CntE", "Lwr "))))
|
||||
@@ -1478,17 +1479,28 @@ fn stylize_and_pixelate_filters_open_from_photoshop_shapes() {
|
||||
assert_eq!(fltr("filter.pixelate.crystallize", json!({})), Some(Value::Descriptor(Descriptor::new("Crst").with("ClSz", Value::Integer(10)))));
|
||||
assert_eq!(fltr("filter.pixelate.pointillize", json!({})), Some(Value::Descriptor(Descriptor::new("Pntl").with("ClSz", Value::Integer(5)))));
|
||||
assert_eq!(
|
||||
fltr("filter.pixelate.crystallize", json!({"cellSize": 1e9})),
|
||||
fltr("filter.pixelate.crystallize", json!({"cellSize": 300})),
|
||||
Some(Value::Descriptor(Descriptor::new("Crst").with("ClSz", Value::Integer(300))))
|
||||
);
|
||||
assert_eq!(fltr("filter.pixelate.pointillize", json!({"cellSize": 2.6})), Some(Value::Descriptor(Descriptor::new("Pntl").with("ClSz", Value::Integer(3)))));
|
||||
assert_eq!(
|
||||
fltr("filter.pixelate.pointillize", json!({"cellSize": "big"})),
|
||||
Some(Value::Descriptor(Descriptor::new("Pntl").with("ClSz", Value::Integer(5))))
|
||||
);
|
||||
assert_eq!(
|
||||
fltr("filter.stylize.traceContour", json!({"level": -40, "edge": "upper"})),
|
||||
fltr("filter.stylize.traceContour", json!({"level": 0, "edge": "upper"})),
|
||||
Some(Value::Descriptor(Descriptor::new("TrcC").with("Lvl ", Value::Integer(0)).with("Edg ", enumv("CntE", "Upr "))))
|
||||
);
|
||||
assert_eq!(
|
||||
fltr("filter.stylize.traceContour", json!({"level": 255.4})),
|
||||
Some(Value::Descriptor(Descriptor::new("TrcC").with("Lvl ", Value::Integer(255)).with("Edg ", enumv("CntE", "Lwr "))))
|
||||
);
|
||||
let crst = |v: i32| Some(Value::Descriptor(Descriptor::new("Crst").with("ClSz", Value::Integer(v))));
|
||||
let pntl = |v: i32| Some(Value::Descriptor(Descriptor::new("Pntl").with("ClSz", Value::Integer(v))));
|
||||
let trcc = |v: i32, e: &str| Some(Value::Descriptor(Descriptor::new("TrcC").with("Lvl ", Value::Integer(v)).with("Edg ", enumv("CntE", e))));
|
||||
assert_eq!(fltr("filter.pixelate.crystallize", json!({"cellSize": 1e9})), crst(300));
|
||||
assert_eq!(fltr("filter.pixelate.crystallize", json!({"cellSize": 2.4})), crst(3));
|
||||
assert_eq!(fltr("filter.pixelate.pointillize", json!({"cellSize": "big"})), pntl(5));
|
||||
assert_eq!(fltr("filter.pixelate.pointillize", json!({"cellSize": null})), pntl(5));
|
||||
assert_eq!(fltr("filter.stylize.traceContour", json!({"level": -40, "edge": "upper"})), trcc(0, "Upr "));
|
||||
assert_eq!(fltr("filter.stylize.traceContour", json!({"level": 255.5})), trcc(255, "Lwr "));
|
||||
assert_eq!(fltr("filter.stylize.traceContour", json!({"level": [128]})), trcc(128, "Lwr "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+65
-23
@@ -286,32 +286,40 @@ fn font_resolver() -> usvg::FontResolver<'static> {
|
||||
/// Parse options for an untrusted drawing. `<image href>` resolves only `data:` URLs: a file path
|
||||
/// or relative href is never read, so opening an SVG cannot pull local files (`/etc/...`,
|
||||
/// `~/.ssh/...`) into the document. Embedded SVG images get no `<image>` resolution at all
|
||||
/// (`usvg` already blanks it for sub-SVGs).
|
||||
fn options(fontdb: Arc<usvg::fontdb::Database>) -> usvg::Options<'static> {
|
||||
/// (`usvg` already blanks it for sub-SVGs). An embedded SVG may nest `depth_left` levels: it is
|
||||
/// parsed on top of the drawing's own recursion, so the two depths share [`MAX_XML_DEPTH`].
|
||||
fn options(fontdb: Arc<usvg::fontdb::Database>, depth_left: usize) -> usvg::Options<'static> {
|
||||
let default_resolver = usvg::ImageHrefResolver::default_data_resolver();
|
||||
let resolve_data: usvg::ImageHrefDataResolverFn<'static> = Box::new(move |mime, data, opts| {
|
||||
// Reject embedded images whose decoded RGBA storage would exceed the budget before
|
||||
// resvg (which allocates the full intrinsic image before any destination scaling) ever
|
||||
// sees them. Formats without a cheap header probe fall through unchanged; their decode
|
||||
// path is bounded elsewhere or accepted as-is, matching the previous behavior.
|
||||
if let Some(probed) = embedded_image_dimensions(mime, &data) {
|
||||
let probed = embedded_image_dimensions(mime, &data);
|
||||
if let Some(probed) = probed {
|
||||
let (w, h) = probed?;
|
||||
let decoded = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4);
|
||||
if decoded > MAX_EMBEDDED_IMAGE_BYTES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// An embedded `image/svg+xml` value can itself be gzip-compressed (SVGZ-in-data-URL).
|
||||
// The default resolver's sub-SVG loader checks for the gzip magic and decompresses
|
||||
// again with no byte limit of its own. Apply our bounded inflate first: if the data is
|
||||
// gzip-compressed, hand the default resolver the already-decompressed (and
|
||||
// size-checked) bytes, which no longer start with the gzip magic, so it parses them
|
||||
// directly instead of decompressing a second time.
|
||||
if (mime == "image/svg+xml" || mime == "text/plain") && data.starts_with(&[0x1f, 0x8b]) {
|
||||
return match inflate(&data) {
|
||||
Ok(xml) if !xml.starts_with(&[0x1f, 0x8b]) => default_resolver("image/svg+xml", std::sync::Arc::new(xml.into_owned()), opts),
|
||||
_ => None,
|
||||
// An embedded SVG (`image/svg+xml`, or `text/plain` that isn't one of the raster
|
||||
// formats probed above) is parsed by the default resolver with none of `parse_with`'s
|
||||
// checks, so they run here. It can itself be gzip-compressed (SVGZ-in-data-URL), and the
|
||||
// default resolver's sub-SVG loader would decompress again with no byte limit of its
|
||||
// own: hand it our bounded inflate's output instead, which no longer starts with the
|
||||
// gzip magic. Nesting deeper than `depth_left` drops the image rather than overflowing
|
||||
// the parser's stack.
|
||||
if mime == "image/svg+xml" || (mime == "text/plain" && probed.is_none()) {
|
||||
let (mime, xml) = match inflate(&data) {
|
||||
Ok(std::borrow::Cow::Borrowed(_)) => (mime, Arc::clone(&data)),
|
||||
Ok(std::borrow::Cow::Owned(xml)) if !xml.starts_with(&[0x1f, 0x8b]) => ("image/svg+xml", Arc::new(xml)),
|
||||
_ => return None,
|
||||
};
|
||||
if nesting_depth(&xml, depth_left) > depth_left {
|
||||
return None;
|
||||
}
|
||||
return default_resolver(mime, xml, opts);
|
||||
}
|
||||
default_resolver(mime, data, opts)
|
||||
});
|
||||
@@ -344,13 +352,14 @@ fn parse(bytes: &[u8]) -> Result<Parsed, IoError> {
|
||||
/// [`parse`] with the font database `fonts` supplies (the tests hand in an empty one).
|
||||
fn parse_with(bytes: &[u8], fonts: fn() -> Arc<usvg::fontdb::Database>) -> Result<Parsed, IoError> {
|
||||
let xml = inflate(bytes)?;
|
||||
if nesting_depth(&xml, MAX_XML_DEPTH) > MAX_XML_DEPTH {
|
||||
let depth = nesting_depth(&xml, MAX_XML_DEPTH);
|
||||
if depth > MAX_XML_DEPTH {
|
||||
return Err(IoError::Svg(format!("elements are nested more than {MAX_XML_DEPTH} levels deep")));
|
||||
}
|
||||
let has_text = xml.windows(4).any(|w| w == b"text");
|
||||
let fontdb = if has_text { fonts() } else { Arc::new(usvg::fontdb::Database::new()) };
|
||||
let fontless_text = has_text && fontdb.faces().next().is_none();
|
||||
let opt = options(fontdb);
|
||||
let opt = options(fontdb, MAX_XML_DEPTH.saturating_sub(depth));
|
||||
// Parse the already-bounded-and-decoded text with from_str, not from_data: from_data
|
||||
// re-checks the gzip magic and would decompress a second time without our size limit if
|
||||
// the inflated content happened to start with a nested gzip stream.
|
||||
@@ -379,7 +388,7 @@ fn inflate(bytes: &[u8]) -> Result<std::borrow::Cow<'_, [u8]>, IoError> {
|
||||
/// then `limit + 1`). Comments, CDATA sections, processing instructions and the DOCTYPE add
|
||||
/// nothing; quoted attribute values may hold `>` or `/>`. Malformed markup is only counted
|
||||
/// loosely: the parser rejects it afterwards.
|
||||
fn nesting_depth(xml: &[u8], limit: usize) -> usize {
|
||||
pub(crate) fn nesting_depth(xml: &[u8], limit: usize) -> usize {
|
||||
let after = |from: usize, pat: &[u8]| -> usize {
|
||||
xml.get(from..).and_then(|s| s.windows(pat.len()).position(|w| w == pat)).map_or(xml.len(), |p| from.saturating_add(p).saturating_add(pat.len()))
|
||||
};
|
||||
@@ -498,14 +507,47 @@ fn import_parsed_with_budget(name: &str, parsed: Parsed, max_group_depth: usize,
|
||||
pub fn rasterize(bytes: &[u8], scale: f32) -> Result<photocraft_compose::Buffer, IoError> {
|
||||
let tree = parse(bytes)?.tree;
|
||||
let cv = canvas(&tree, &mut Vec::new());
|
||||
let k = cv.scale * scale.clamp(1.0 / 64.0, 64.0);
|
||||
let pixmap = render(&tree, cv.scale * scale.clamp(1.0 / 64.0, 64.0))?;
|
||||
let rect = Rect::new(0, 0, pixmap.width() as i32, pixmap.height() as i32);
|
||||
let px = pixmap.pixels().iter().map(|p| rgba(p.demultiply())).collect();
|
||||
Ok(photocraft_compose::Buffer { rect, px })
|
||||
}
|
||||
|
||||
/// The whole drawing at one pixel per SVG unit, scaled down to fit [`MAX_SIDE`] a side and
|
||||
/// [`MAX_RASTER_PIXELS`] in all: its width, height and straight-alpha RGBA8 pixels. Paste uses it
|
||||
/// for a drawing copied as SVG markup.
|
||||
pub fn rasterize_rgba8(bytes: &[u8]) -> Result<(u32, u32, Vec<u8>), IoError> {
|
||||
let tree = parse(bytes)?.tree;
|
||||
let cv = canvas(&tree, &mut Vec::new());
|
||||
let (w, h) = (f64::from(tree.size().width() * cv.scale), f64::from(tree.size().height() * cv.scale));
|
||||
// The largest k ≤ 1 with (w·k + 2)(h·k + 2) ≤ the limit: `render` rounds each side up, and
|
||||
// f32 rounding can add one more pixel.
|
||||
let max = MAX_RASTER_PIXELS as f64;
|
||||
let fit = if (w + 2.0) * (h + 2.0) <= max {
|
||||
1.0
|
||||
} else {
|
||||
let (a, b) = (w * h, 2.0 * (w + h));
|
||||
(-b + (b * b - 4.0 * a * (4.0 - max)).sqrt()) / (2.0 * a)
|
||||
};
|
||||
let pixmap = render(&tree, cv.scale * fit as f32)?;
|
||||
let px = pixmap
|
||||
.pixels()
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let c = p.demultiply();
|
||||
[c.red(), c.green(), c.blue(), c.alpha()]
|
||||
})
|
||||
.collect();
|
||||
Ok((pixmap.width(), pixmap.height(), px))
|
||||
}
|
||||
|
||||
/// `tree` drawn at `k` pixels per SVG unit on a transparent pixmap whose sides round up.
|
||||
fn render(tree: &usvg::Tree, k: f32) -> Result<tiny_skia::Pixmap, IoError> {
|
||||
let side = |v: f32| ((v * k).ceil() as u32).max(1);
|
||||
let (w, h) = (side(tree.size().width()), side(tree.size().height()));
|
||||
let mut pixmap = pixmap(w, h).ok_or_else(|| IoError::Svg(format!("{w} × {h} px is too large to rasterise")))?;
|
||||
resvg::render(&tree, tiny_skia::Transform::from_scale(k, k), &mut pixmap.as_mut());
|
||||
let rect = Rect::new(0, 0, w as i32, h as i32);
|
||||
let px = pixmap.pixels().iter().map(|p| rgba(p.demultiply())).collect();
|
||||
Ok(photocraft_compose::Buffer { rect, px })
|
||||
resvg::render(tree, tiny_skia::Transform::from_scale(k, k), &mut pixmap.as_mut());
|
||||
Ok(pixmap)
|
||||
}
|
||||
|
||||
/// A transparent pixmap, or `None` past [`MAX_RASTER_PIXELS`].
|
||||
@@ -672,7 +714,7 @@ impl Conv {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut l = Layer::new(self.name(g.id(), 1), LayerContent::Group(Group { children, expanded: false, artboard: None }));
|
||||
let mut l = Layer::new(self.name(g.id(), 1), LayerContent::Group(Group { children, expanded: false, artboard: None, frame_stroke: None }));
|
||||
l.opacity = g.opacity().get();
|
||||
l.blend = blend(g.blend_mode());
|
||||
l.vector_mask = clip;
|
||||
@@ -699,7 +741,7 @@ impl Conv {
|
||||
} else {
|
||||
t.id().to_string()
|
||||
};
|
||||
out.push(Layer::new(name, LayerContent::Group(Group { children, expanded: false, artboard: None })));
|
||||
out.push(Layer::new(name, LayerContent::Group(Group { children, expanded: false, artboard: None, frame_stroke: None })));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,9 +414,31 @@ fn huge_drawings_scale_to_the_side_limit() {
|
||||
assert_eq!(w.len(), 1);
|
||||
}
|
||||
|
||||
/// A pasted drawing is one pixel per SVG unit, and a drawing past the pixel limit is scaled
|
||||
/// down to fit it, keeping its proportions, instead of failing.
|
||||
#[test]
|
||||
fn rgba8_renders_at_its_own_size_within_the_pixel_limit() {
|
||||
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="20" height="20" fill="#ff0000"/></svg>"##;
|
||||
let (w, h, px) = rasterize_rgba8(svg).unwrap();
|
||||
assert_eq!((w, h, px.len()), (40, 20, 40 * 20 * 4));
|
||||
assert_eq!(px[..4], [255, 0, 0, 255], "the rectangle");
|
||||
assert_eq!(px[(10 * 40 + 30) * 4..][..4], [0, 0, 0, 0], "transparent beside it");
|
||||
for (side_w, side_h) in [(10_000u32, 5_000u32), (20_000, 20_000), (20_000, 2_000)] {
|
||||
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{side_w}\" height=\"{side_h}\"/>");
|
||||
let (w, h, px) = rasterize_rgba8(svg.as_bytes()).unwrap();
|
||||
let pixels = u64::from(w) * u64::from(h);
|
||||
assert!(pixels <= MAX_RASTER_PIXELS && pixels > MAX_RASTER_PIXELS / 2, "{side_w} × {side_h} became {w} × {h}");
|
||||
assert!(w <= MAX_SIDE && h <= MAX_SIDE, "{w} × {h}");
|
||||
let (aspect, want) = (f64::from(w) / f64::from(h), f64::from(side_w) / f64::from(side_h));
|
||||
assert!((aspect / want - 1.0).abs() < 0.01, "{w} × {h} keeps {side_w} × {side_h}");
|
||||
assert_eq!(px.len() as u64, pixels * 4);
|
||||
}
|
||||
assert!(rasterize_rgba8(b"<svg xmlns=\"http://www.w3.org/2000/svg\"><rect").is_err(), "malformed markup is an error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_data_rejects_oversized_and_malformed_embedded_png_and_webp() {
|
||||
let opt = options(Arc::new(usvg::fontdb::Database::new()));
|
||||
let opt = options(Arc::new(usvg::fontdb::Database::new()), MAX_XML_DEPTH);
|
||||
let resolve = &opt.image_href_resolver.resolve_data;
|
||||
let img = photocraft_codecs::Image::from_u8(2, 2, photocraft_codecs::ChannelLayout::Rgb, vec![255; 12]).unwrap();
|
||||
let valid_png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
@@ -448,7 +470,7 @@ fn jpeg_dimensions_come_from_the_frame_header_and_oversized_jpegs_are_refused()
|
||||
assert_eq!(jpeg_dimensions(&jpeg(40_000, 30_000)), Some((30_000, 40_000)));
|
||||
assert_eq!(jpeg_dimensions(&[0xFF, 0xD8, 0xFF, 0xDA, 0, 2]), None);
|
||||
assert_eq!(jpeg_dimensions(&[0xFF, 0xD8]), None);
|
||||
let opt = options(Arc::new(usvg::fontdb::Database::new()));
|
||||
let opt = options(Arc::new(usvg::fontdb::Database::new()), MAX_XML_DEPTH);
|
||||
let resolve = &opt.image_href_resolver.resolve_data;
|
||||
// 1.2 billion pixels would be 4.8 GB of RGBA before resvg scaled anything down.
|
||||
assert!(resolve("image/jpeg", Arc::new(jpeg(40_000, 30_000)), &opt).is_none());
|
||||
@@ -463,7 +485,7 @@ fn resolve_data_rejects_nested_gzip_and_handles_text_plain_svgz() {
|
||||
e.write_all(bytes).unwrap();
|
||||
e.finish().unwrap()
|
||||
}
|
||||
let opt = options(Arc::new(usvg::fontdb::Database::new()));
|
||||
let opt = options(Arc::new(usvg::fontdb::Database::new()), MAX_XML_DEPTH);
|
||||
let resolve = &opt.image_href_resolver.resolve_data;
|
||||
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"4\" height=\"4\"><rect width=\"4\" height=\"4\"/></svg>";
|
||||
let once = gz(svg);
|
||||
|
||||
@@ -233,10 +233,9 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr
|
||||
// TIFF already round-trips as.
|
||||
// Layer data is written PSD-style ("Adobe Photoshop Document Data Block", 4-byte lengths), as
|
||||
// Photoshop writes it: Photoshop does not read the PSB-style header ("... Data V0002") in a
|
||||
// TIFF and opens only the composite, as one locked Background. The exporter's size estimate
|
||||
// counts layer planes uncompressed, so a document with many canvas-sized layers passes 2 GB
|
||||
// while its real layer data is a small fraction of that; it is not used here. PSB-style data
|
||||
// is written only past 30000 px, when asked for, or when a length really overflows 32 bits.
|
||||
// TIFF and opens only the composite, as one locked Background. The exporter's switch to PSB
|
||||
// on the encoded file size is not used here: PSB-style data is written only past 30000 px,
|
||||
// when asked for, or when a length really overflows 32 bits.
|
||||
let user_channels = !doc.channels.is_empty() || doc.quick_mask.is_some();
|
||||
let force_psb = opts.force_psb || big;
|
||||
let psd_opts = PsdExportOptions {
|
||||
@@ -970,11 +969,11 @@ mod tests {
|
||||
|
||||
use crate::{ExportOptions, export};
|
||||
|
||||
/// Hidden solid fills on a 2048² canvas: the PSD size estimate counts each one as a
|
||||
/// canvas-sized plane (16 MiB at 8 bits), so 140 of them pass 2 GB, while their real pixels
|
||||
/// (the cached rendering, pruned to nothing) take almost no space. A layered TIFF of such a
|
||||
/// document must still carry PSD-style layer data: Photoshop does not read the PSB-style
|
||||
/// header in a TIFF and opens only the composite, as one locked Background.
|
||||
/// Hidden solid fills on a 2048² canvas: counted as canvas-sized planes (16 MiB each at 8
|
||||
/// bits), 140 of them pass 2 GB, while their real pixels (the cached rendering, pruned to
|
||||
/// nothing) take almost no space. A layered TIFF of such a document must still carry
|
||||
/// PSD-style layer data: Photoshop does not read the PSB-style header in a TIFF and opens only
|
||||
/// the composite, as one locked Background.
|
||||
fn many_cheap_fills() -> Document {
|
||||
let mut doc = Document::new("many fills", photocraft_geom::Size::new(2048, 2048), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||||
for i in 0..140 {
|
||||
@@ -990,7 +989,6 @@ mod tests {
|
||||
#[test]
|
||||
fn layered_tiff_ignores_the_uncompressed_size_estimate() {
|
||||
let doc = many_cheap_fills();
|
||||
assert!(crate::psd_export::psd_size_exceeds_limit(&doc), "the fixture must pass the 2 GB estimate");
|
||||
|
||||
let r = export(&doc, "x.tif", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap();
|
||||
let (_, layers) = photocraft_codecs::tiff_photoshop_tags(&r.bytes);
|
||||
@@ -1000,9 +998,9 @@ mod tests {
|
||||
let back = crate::import("x.tif", &r.bytes).unwrap().document;
|
||||
assert_eq!(back.layers.len(), doc.layers.len());
|
||||
|
||||
// A .psd save still switches to PSB on the estimate, as before.
|
||||
// A .psd save switches to PSB on the encoded size, which is small here: it stays PSD.
|
||||
let psd = export(&doc, "x.psd", &ExportOptions::default()).unwrap();
|
||||
assert_eq!(psd.bytes.get(4..6), Some(&[0u8, 2][..]), "PSD saves keep the size-estimate switch");
|
||||
assert_eq!(psd.bytes.get(4..6), Some(&[0u8, 1][..]), "PSD saves switch on the encoded size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! XML parsing with a nesting cap. roxmltree recurses once per element level, so a few thousand
|
||||
//! nested tags (a 30 KB file or command param) overflow a 2 MB thread stack, an abort no guard
|
||||
//! can catch. Every roxmltree caller that reads a file or a param goes through [`parse`].
|
||||
|
||||
use roxmltree::{Document, ParsingOptions};
|
||||
|
||||
/// Deepest element nesting accepted, the same as for SVG drawings: real files stay far below.
|
||||
pub const MAX_DEPTH: usize = crate::svg::MAX_XML_DEPTH;
|
||||
|
||||
/// Parses `text`, refusing nesting deeper than [`MAX_DEPTH`] before roxmltree sees it.
|
||||
///
|
||||
/// With `opts.allow_dtd` an internal DTD subset is refused too: its entities can expand into
|
||||
/// markup the depth check never sees. An external DOCTYPE (a public or system id) still parses.
|
||||
pub fn parse<'i>(text: &'i str, opts: ParsingOptions<'i>) -> Result<Document<'i>, String> {
|
||||
if opts.allow_dtd && internal_subset(text.as_bytes()) {
|
||||
return Err("internal DTD subsets aren't supported".into());
|
||||
}
|
||||
if crate::svg::nesting_depth(text.as_bytes(), MAX_DEPTH) > MAX_DEPTH {
|
||||
return Err(format!("elements are nested more than {MAX_DEPTH} levels deep"));
|
||||
}
|
||||
Document::parse_with_options(text, opts).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Does a DOCTYPE have an internal subset (`<!DOCTYPE x [ … ]>`)? Every `<!DOCTYPE` in the text
|
||||
/// is checked, not only the one a walk over the prolog reaches: a quoted `?>` in the XML
|
||||
/// declaration must not hide it. One inside a comment may be refused needlessly; that fails safe.
|
||||
///
|
||||
/// Each `<!DOCTYPE` starts a scan over what follows: the name and the quoted public/system ids
|
||||
/// come first, a `[` outside quotes opens the subset, a `>` ends a DOCTYPE without one. Scans in
|
||||
/// the same quote state go on alike, so one pass keeps just three flags (outside quotes, in `"`,
|
||||
/// in `'`) and stays linear however many `<!DOCTYPE`s a comment holds.
|
||||
fn internal_subset(xml: &[u8]) -> bool {
|
||||
const DOCTYPE: &[u8] = b"<!DOCTYPE";
|
||||
let (mut bare, mut double, mut single) = (false, false, false);
|
||||
for (at, &b) in xml.iter().enumerate() {
|
||||
if at.checked_sub(DOCTYPE.len()).and_then(|start| xml.get(start..at)) == Some(DOCTYPE) {
|
||||
bare = true;
|
||||
}
|
||||
if bare && b == b'[' {
|
||||
return true;
|
||||
}
|
||||
(bare, double, single) = (
|
||||
(bare && !matches!(b, b'"' | b'\'' | b'>')) || (double && b == b'"') || (single && b == b'\''),
|
||||
(double && b != b'"') || (bare && b == b'"'),
|
||||
(single && b != b'\'') || (bare && b == b'\''),
|
||||
);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn nested(levels: usize) -> String {
|
||||
format!("<a>{}{}</a>", "<g>".repeat(levels), "</g>".repeat(levels))
|
||||
}
|
||||
|
||||
fn dtd<'a>() -> ParsingOptions<'a> {
|
||||
ParsingOptions { allow_dtd: true, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_past_the_cap_is_an_error_not_a_stack_overflow() {
|
||||
let deep = nested(20_000);
|
||||
std::thread::Builder::new()
|
||||
.stack_size(512 * 1024)
|
||||
.spawn(move || {
|
||||
for opts in [ParsingOptions::default(), dtd()] {
|
||||
let e = parse(&deep, opts).err().unwrap_or_else(|| panic!("parsed"));
|
||||
assert!(e.contains("nested") && e.contains(&MAX_DEPTH.to_string()), "{e}");
|
||||
}
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
// The root plus MAX_DEPTH - 1 levels is exactly MAX_DEPTH deep.
|
||||
assert!(parse(&nested(MAX_DEPTH - 1), ParsingOptions::default()).is_ok());
|
||||
assert!(parse(&nested(MAX_DEPTH), ParsingOptions::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markup_in_comments_cdata_pis_and_attributes_adds_no_depth() {
|
||||
let g = "<g>".repeat(2 * MAX_DEPTH);
|
||||
let xml = format!("<?xml version=\"1.0\"?><!-- {g} --><a t=\"<> /> >\"><![CDATA[ {g} ]]><?pi {g} ?><b/></a>");
|
||||
assert!(parse(&xml, ParsingOptions::default()).is_ok());
|
||||
}
|
||||
|
||||
/// The scan per `<!DOCTYPE` that [`internal_subset`] folds into one pass.
|
||||
fn internal_subset_per_doctype(xml: &[u8]) -> bool {
|
||||
const DOCTYPE: &[u8] = b"<!DOCTYPE";
|
||||
xml.windows(DOCTYPE.len()).enumerate().filter(|(_, w)| *w == DOCTYPE).any(|(at, _)| {
|
||||
let mut quote = None;
|
||||
for &b in &xml[at + DOCTYPE.len()..] {
|
||||
match quote {
|
||||
Some(q) if b == q => quote = None,
|
||||
Some(_) => {}
|
||||
None if b == b'"' || b == b'\'' => quote = Some(b),
|
||||
None if b == b'[' => return true,
|
||||
None if b == b'>' => return false,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_one_pass_doctype_scan_agrees_with_a_scan_per_doctype() {
|
||||
let parts: [&[u8]; 7] = [b"<!DOCTYPE", b"\"", b"'", b"[", b">", b"a", b"<!-- "];
|
||||
// Every sequence of up to six parts, so the scans overlap in each quote state.
|
||||
let mut checked = 0;
|
||||
for len in 0..=6u32 {
|
||||
for mut code in 0..parts.len().pow(len) {
|
||||
let mut xml = Vec::new();
|
||||
for _ in 0..len {
|
||||
xml.extend_from_slice(parts[code % parts.len()]);
|
||||
code /= parts.len();
|
||||
}
|
||||
assert_eq!(internal_subset(&xml), internal_subset_per_doctype(&xml), "{}", String::from_utf8_lossy(&xml));
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked > 100_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_doctypes_in_a_comment_scan_in_linear_time() {
|
||||
// A scan per `<!DOCTYPE` reads on to the end from each of the ~1.9 million here, some
|
||||
// 10^13 steps; one pass reads the 16 MiB once.
|
||||
let xml = format!("<!-- {} --><a/>", "<!DOCTYPE".repeat((16 << 20) / 9));
|
||||
let started = std::time::Instant::now();
|
||||
assert!(parse(&xml, dtd()).is_ok());
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_internal_subset_is_refused_an_external_doctype_parses() {
|
||||
let krita = "<?xml version=\"1.0\"?>\n<!-- c --><!DOCTYPE DOC PUBLIC '-//KDE//DTD krita 2.0//EN' 'http://www.calligra.org/DTD/[krita].dtd'>\n<DOC/>";
|
||||
assert!(parse(krita, dtd()).is_ok());
|
||||
let g = ("<g>".repeat(20_000), "</g>".repeat(20_000));
|
||||
for xml in [
|
||||
"<!DOCTYPE a [<!ENTITY e \"x\">]><a>&e;</a>".to_string(),
|
||||
"\u{feff}<?xml version=\"1.0\"?><!-- ' --> <!DOCTYPE a SYSTEM \"a.dtd\" [<!-- ' -->]><a/>".to_string(),
|
||||
format!("<!DOCTYPE a [<!ENTITY e \"{}{}\">]><a>&e;</a>", g.0, g.1),
|
||||
// roxmltree reads the declaration's values as quoted attributes, so they may hold `?>`.
|
||||
format!("<?xml version=\"?>\"?><!DOCTYPE a [<!ENTITY e \"{}{}\">]><a>&e;</a>", g.0, g.1),
|
||||
format!("<?xml version=\"?>\"?><!DOCTYPE a [<!-- ' -->]><a>{}{}</a><!-- ' -->", g.0, g.1),
|
||||
] {
|
||||
let e = parse(&xml, dtd()).err().unwrap_or_else(|| panic!("parsed {xml:.60}"));
|
||||
assert!(e.contains("internal DTD"), "{e}");
|
||||
}
|
||||
// Without allow_dtd roxmltree refuses any DOCTYPE itself.
|
||||
assert!(parse(krita, ParsingOptions::default()).is_err());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user