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:
Vlad Grankovsky
2026-10-27 21:15:06 +01:00
185 changed files with 6244 additions and 724 deletions
Generated
+167 -7
View File
@@ -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
View File
@@ -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"
+72 -23
View File
@@ -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())),
+169
View File
@@ -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.
+3
View File
@@ -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)" },
]
+38
View File
@@ -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()));
+39 -3
View File
@@ -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 {
+66
View File
@@ -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");
+55 -7
View File
@@ -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 }] }
+2 -1
View File
@@ -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}");
+10 -2
View File
@@ -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
+154
View File
@@ -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);
}
}
}
+62 -2
View File
@@ -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 = || {
+34 -3
View File
@@ -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) {
+5 -1
View File
@@ -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)
}
+27 -14
View File
@@ -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() {
+1 -1
View File
@@ -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);
+17
View File
@@ -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'
+52 -7
View File
@@ -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"),
}
}
}
+23
View File
@@ -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 {
+6 -2
View File
@@ -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");
}
}
+12 -11
View File
@@ -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);
}
+59
View File
@@ -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);
+7 -1
View File
@@ -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());
+25 -9
View File
@@ -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 {
+48 -1
View File
@@ -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(),
+2 -1
View File
@@ -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)
+2 -2
View File
@@ -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.
+9 -2
View File
@@ -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,
})
}
+38
View File
@@ -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() {
+3 -2
View File
@@ -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;
+29 -9
View File
@@ -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]
}
+11 -3
View File
@@ -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 => {
+3 -3
View File
@@ -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) {
+216
View File
@@ -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)`,
+9 -4
View File
@@ -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)
}
}
+25
View File
@@ -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();
+2 -2
View File
@@ -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;
+1 -4
View File
@@ -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)
}
}
+25 -6
View File
@@ -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");
}
}
+14 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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);
+185 -17
View File
@@ -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()) {
+215 -6
View File
@@ -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
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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)?;
+13 -8
View File
@@ -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!(
+2
View File
@@ -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),
})
}
+10 -1
View File
@@ -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 =
+5 -1
View File
@@ -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,
+28 -4
View File
@@ -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);
+1 -1
View File
@@ -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};
+1
View File
@@ -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);
+28 -3
View File
@@ -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;
+85
View File
@@ -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
+2
View File
@@ -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();
+37 -23
View File
@@ -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>> {
+9 -4
View File
@@ -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 } => {
+3
View File
@@ -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
View File
@@ -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() {
+51
View File
@@ -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();
}
+37
View File
@@ -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());
}
+1
View File
@@ -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;
+19
View File
@@ -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() {
+19 -2
View File
@@ -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)
}
+2 -1
View File
@@ -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}");
}
+50 -1
View File
@@ -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]
+93
View File
@@ -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
View File
@@ -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)];
+103 -2
View File
@@ -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");
+2 -2
View File
@@ -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.
+11 -1
View File
@@ -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 {
+3 -3
View File
@@ -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)?;
+17
View File
@@ -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.
+1
View File
@@ -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};
+19 -10
View File
@@ -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
}
+1 -1
View 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"));
+10
View File
@@ -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()
+9 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+19 -9
View File
@@ -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)
}
+41 -107
View File
@@ -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);
}
}
+158 -30
View File
@@ -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}");
}
}
}
}
}
}
+147 -19
View File
@@ -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
+3 -1
View File
@@ -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);
+2 -1
View File
@@ -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)
}
+19 -7
View File
@@ -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
View File
@@ -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 })));
}
}
}
+25 -3
View File
@@ -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);
+10 -12
View File
@@ -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]
+156
View File
@@ -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=\"&lt;&gt; /&gt; >\"><![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