Merge pull request #9880 from aiongg/batch-1027

Integration batch: 22 PRs on main
This commit is contained in:
aiongg
2026-10-27 14:25:13 -04:00
committed by GitHub
167 changed files with 5611 additions and 564 deletions
+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.
+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");
}
}
+9 -7
View File
@@ -36,17 +36,18 @@ pub(crate) fn copy(s: &mut Session) -> 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) = match d.channel_view.target {
// The surface the values come from, which of its samples is the channel, and whether it stores
// ink (CMYK, a spot channel) rather than tone.
let (src, k, ink) = match d.channel_view.target {
ChannelTarget::Composite | ChannelTarget::LayerMask | ChannelTarget::VectorMask => return Ok(None),
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())
}
};
let n = src.format().channels();
@@ -64,7 +65,8 @@ pub(crate) fn copy(s: &mut Session) -> 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() {
px.push(p.get(k).copied().unwrap_or(0.0));
// 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);
@@ -91,7 +93,7 @@ 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 (n, mode) = (surf.format().channels(), surf.format().mode);
if k >= n {
return Err(EngineError::Other(format!("no colour channel {k} in this layer")));
}
@@ -105,7 +107,7 @@ 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 v = photocraft_color::convert::rgb_to_gray([s[0], s[1], s[2]]);
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);
}
+54
View File
@@ -72,6 +72,60 @@ 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");
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;
@@ -592,7 +593,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);
@@ -1273,6 +1278,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() {
+4 -2
View File
@@ -113,6 +113,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, filter_mask_curve_channel, isolated_curve_channel, isolated_curve_channel_for,
next_alpha_name,
@@ -123,8 +124,9 @@ 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, read_target_surface, restrict_to_color, routed, target_of, target_surface, targeted_channel, targeted_color,
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, read_target_surface, restrict_to_color, routed, target_of, target_surface, targeted_channel,
targeted_color,
};
pub use targeting::{follows_filter_mask, follows_target};
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;
@@ -133,6 +133,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
@@ -506,8 +506,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(())
@@ -761,11 +769,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
@@ -1138,6 +1138,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
@@ -574,7 +574,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(())
})?;
@@ -640,7 +640,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
@@ -96,6 +96,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;
@@ -160,9 +161,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.
@@ -241,6 +242,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
@@ -163,6 +163,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);
@@ -223,14 +242,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,
+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]
+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);
+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);
}
}
+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());
}
}
+15 -3
View File
@@ -80,9 +80,20 @@ fn adjustments() -> Vec<(Adjustment, bool)> {
(Adjustment::PhotoFilter { color: [q16(0), q16(39321), q16(65535)], density: 0.3, preserve_luminosity: false }, true),
(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 }, true),
(Adjustment::ChannelMixer { matrix: [[0.4, 0.4, 0.2, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], monochrome: true }, true),
(Adjustment::ColorLookup { name: "Look.cube".into(), lut: Some(Arc::new(lut.data)), size: 5, tetrahedral: false, dither: false }, true),
// The LUT file is embedded as loaded (here a .cube), so it reads back with its source.
(
Adjustment::ColorLookup {
name: "Look.cube".into(),
source: Some(adjust::LutSource { format: adjust::LutFormat::Cube, bytes: Arc::new(photocraft_cms::lutfile::write_cube(&lut).into_bytes()) }),
lut: Some(Arc::new(lut.data)),
size: 5,
tetrahedral: false,
dither: false,
},
true,
),
// No table chosen: written as an identity cube (renders the same, comes back with a table).
(Adjustment::ColorLookup { name: String::new(), lut: None, size: 0, tetrahedral: false, dither: false }, false),
(Adjustment::ColorLookup { name: String::new(), lut: None, size: 0, tetrahedral: false, dither: false, source: None }, false),
(Adjustment::Invert, true),
(Adjustment::Posterize { levels: 4 }, true),
(Adjustment::Threshold { level: g(140) }, true),
@@ -207,7 +218,8 @@ fn every_adjustment_kind_round_trips_16bit() {
fn color_lookup_tetrahedral_warns_on_psd_save() {
let lut = photocraft_cms::lutfile::LutFile::from_fn("t", 2, |c| if c == [1.0; 3] { [1.0; 3] } else { [0.0; 3] });
for tetrahedral in [true, false] {
let adj = Adjustment::ColorLookup { name: "test.cube".into(), lut: Some(Arc::new(lut.data.clone())), size: 2, tetrahedral, dither: false };
let adj =
Adjustment::ColorLookup { name: "test.cube".into(), lut: Some(Arc::new(lut.data.clone())), size: 2, tetrahedral, dither: false, source: None };
let mut d = Document::new("lut", photocraft_geom::Size::new(8, 8), ColorMode::Rgb, SampleType::U8);
let fmt = d.pixel_format();
d.layers.push(raster("Background", fmt, d.bounds(), 1, false));
+25 -5
View File
@@ -36,7 +36,8 @@ fn unreadable_adjustments_warn_on_open() {
#[test]
fn readable_adjustments_open_without_warnings() {
let lut: Vec<f32> = (0..8).flat_map(|i| [(i & 1) as f32, ((i >> 1) & 1) as f32, ((i >> 2) & 1) as f32]).collect();
let lookup = Adjustment::ColorLookup { name: "id.cube".into(), size: 2, lut: Some(std::sync::Arc::new(lut)), tetrahedral: false, dither: false };
let lookup =
Adjustment::ColorLookup { name: "id.cube".into(), size: 2, lut: Some(std::sync::Arc::new(lut)), tetrahedral: false, dither: false, source: None };
for adj in [lookup, Adjustment::Invert] {
let (doc, warnings) = reopen(adj, "x.psd", &ExportOptions::default());
assert!(!matches!(&doc.layers[0].content, LayerContent::Adjustment(Adjustment::Unsupported { .. })));
@@ -114,10 +115,15 @@ fn photoshop_layout_color_lookup_round_trips_through_psb() {
let opts = ExportOptions { force_psb: true, ..Default::default() };
let (doc, warnings) = reopen(Adjustment::Unsupported { psd_key: "clrL".into(), raw: raw.as_slice().into() }, "x.psb", &opts);
assert!(warnings.iter().all(|w| !w.contains("Teal Look")), "{warnings:?}");
let LayerContent::Adjustment(Adjustment::ColorLookup { name, size, lut: Some(table), tetrahedral: false, dither: false }) = &doc.layers[0].content else {
let LayerContent::Adjustment(Adjustment::ColorLookup { name, size, lut: Some(table), tetrahedral: false, dither: false, source: Some(source) }) =
&doc.layers[0].content
else {
panic!("{:?}", doc.layers[0].content)
};
assert_eq!(name, "frost.cube");
// The loaded `.cube` file is kept as its source, so a later save can embed it unchanged.
assert_eq!(source.format, photocraft_doc::adjust::LutFormat::Cube);
assert_eq!(source.bytes.as_slice(), b"LUT_3D_SIZE 2\n0 0 0\n0 1 1\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n");
assert_eq!(*size, 2);
let want: Vec<f32> = [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0].into();
assert_eq!(table.as_slice(), want.as_slice());
@@ -139,18 +145,32 @@ fn photoshop_layout_color_lookup_round_trips_through_psb() {
fn short_color_lookup_table_warns_on_open() {
// A stored table shorter than size³×3 renders as the identity; opening must say so, naming
// the layer (#1763).
let short = Adjustment::ColorLookup { name: "t.cube".into(), size: 3, lut: Some(std::sync::Arc::new(vec![0.5; 7])), tetrahedral: false, dither: false };
let short = Adjustment::ColorLookup {
name: "t.cube".into(),
size: 3,
lut: Some(std::sync::Arc::new(vec![0.5; 7])),
tetrahedral: false,
dither: false,
source: None,
};
let (doc, warnings) = reopen(short, "x.pcraft", &ExportOptions::default());
assert!(matches!(&doc.layers[0].content, LayerContent::Adjustment(Adjustment::ColorLookup { .. })), "{:?}", doc.layers[0].content);
let w: Vec<&String> = warnings.iter().filter(|w| w.contains("Teal Look")).collect();
assert_eq!(w.len(), 1, "{warnings:?}");
assert!(w[0].contains("Color Lookup") && w[0].contains("7 values") && w[0].contains("81"), "{}", w[0]);
// A table long enough for its size opens without the note.
let ok = Adjustment::ColorLookup { name: "t.cube".into(), size: 2, lut: Some(std::sync::Arc::new(vec![0.5; 24])), tetrahedral: false, dither: false };
let ok = Adjustment::ColorLookup {
name: "t.cube".into(),
size: 2,
lut: Some(std::sync::Arc::new(vec![0.5; 24])),
tetrahedral: false,
dither: false,
source: None,
};
let (_, warnings) = reopen(ok, "x.pcraft", &ExportOptions::default());
assert!(warnings.iter().all(|w| !w.contains("Teal Look")), "{warnings:?}");
// A missing table renders as the identity too.
let none = Adjustment::ColorLookup { name: "t.cube".into(), size: 2, lut: None, tetrahedral: false, dither: false };
let none = Adjustment::ColorLookup { name: "t.cube".into(), size: 2, lut: None, tetrahedral: false, dither: false, source: None };
let (_, warnings) = reopen(none, "x.pcraft", &ExportOptions::default());
assert!(warnings.iter().any(|w| w.contains("Teal Look") && w.contains("no usable table")), "{warnings:?}");
}
+1
View File
@@ -50,6 +50,7 @@ mod psd_block_layout;
mod psd_damaged_merged;
mod psd_empty_channels;
mod psd_font_identity;
mod psd_frame_stroke;
mod psd_kerning;
mod psd_layer_block_length;
mod psd_maximize_compatibility;
+117
View File
@@ -0,0 +1,117 @@
//! A Photoshop Frame (Frame tool, K) is a layer group whose record carries the frame outline as a
//! vector mask (`vsms`) and its Properties › Stroke as a shape-style `vstk`. The stroke draws
//! along the outline even when nothing is placed in the frame (#4381).
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Document, Fill, Group, Layer, LayerContent, Path, ShapeStroke, Size, StrokeAlign, Subpath, VectorMask};
use photocraft_io::vector_map::{parse_vstk, vstk_bytes};
use photocraft_io::*;
use photocraft_psd::{PsdFile, SectionType, TaggedBlock};
fn red_inside(width: f32) -> ShapeStroke {
ShapeStroke { width, align: StrokeAlign::Inside, paint: Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), ..ShapeStroke::default() }
}
/// An 80 × 60 white document with an empty rectangular frame over (20, 10)–(60, 50).
fn frame_doc(depth: SampleType, stroke: Option<ShapeStroke>) -> Document {
let mut d = Document::with_background("f", Size::new(80, 60), ColorMode::Rgb, depth, Color::WHITE);
let mut g = Layer::group("Frame 1", vec![]);
if let LayerContent::Group(Group { frame_stroke, .. }) = &mut g.content {
*frame_stroke = stroke;
}
g.vector_mask = Some(VectorMask::new(Path::new(vec![Subpath::polygon(&[(20.0, 10.0), (60.0, 10.0), (60.0, 50.0), (20.0, 50.0)])])));
d.layers.push(g);
d
}
fn frame_stroke(d: &Document) -> Option<ShapeStroke> {
d.layers.iter().find_map(|l| match &l.content {
LayerContent::Group(g) => Some(g.frame_stroke.clone()),
_ => None,
})?
}
fn px(d: &Document, x: i32, y: i32) -> [f32; 4] {
photocraft_compose::flatten_to_surface(d, PixelFormat::RGBA8.with_sample(SampleType::F32), None).rgba(x, y)
}
/// The group's own record (its top, `lsct` 1 or 2) in a PSD file.
fn frame_record(f: &mut PsdFile) -> &mut photocraft_psd::LayerRecord {
f.layers_mut()
.iter_mut()
.find(|r| r.section_divider().is_some_and(|s| matches!(s.kind, SectionType::OpenFolder | SectionType::ClosedFolder)))
.expect("the frame's group record")
}
#[test]
fn a_photoshop_frames_vstk_draws_its_stroke_along_the_outline() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
// A file shaped like Photoshop's: the group record carries `vsms` and a `vstk`.
let mut f = document_to_psd(&frame_doc(depth, None));
frame_record(&mut f).blocks.push(TaggedBlock::new(*b"vstk", vstk_bytes(Some(&red_inside(4.0)), true, 72.0)));
let (d, _) = psd_to_document(&f);
assert_eq!(frame_stroke(&d), Some(red_inside(4.0)), "{depth:?}");
// Inside the outline, 4 px wide: red on the frame's edge band, white in the middle and
// outside it.
let close = |a: [f32; 4], b: [f32; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 0.02);
for (x, y) in [(21, 30), (58, 30), (40, 11), (40, 48)] {
assert!(close(px(&d, x, y), [1.0, 0.0, 0.0]), "{depth:?} ({x}, {y}): {:?}", px(&d, x, y));
}
for (x, y) in [(40, 30), (18, 30), (62, 30), (40, 8)] {
assert!(close(px(&d, x, y), [1.0, 1.0, 1.0]), "{depth:?} ({x}, {y}): {:?}", px(&d, x, y));
}
}
}
#[test]
fn a_frame_stroke_round_trips_through_psd() {
let stroke = ShapeStroke { dashes: vec![2.0, 1.0], opacity: 0.5, align: StrokeAlign::Outside, ..red_inside(3.0) };
let d = frame_doc(SampleType::U8, Some(stroke.clone()));
let bytes = export(&d, "f.psd", &ExportOptions::default()).unwrap().bytes;
let back = import("f.psd", &bytes).unwrap().document;
assert_eq!(frame_stroke(&back), Some(stroke.clone()));
// Outside the outline at half opacity: pink just outside the edge.
let p = px(&back, 18, 30);
assert!((p[1] - 0.5).abs() < 0.05 && p[0] > 0.98, "{p:?}");
// Saved again unchanged, the imported `vstk` stays byte for byte.
let mut f = PsdFile::from_bytes(&bytes).unwrap();
let first = frame_record(&mut f).block(b"vstk").unwrap().data.clone();
let again = export(&back, "f.psd", &ExportOptions::default()).unwrap().bytes;
let mut f2 = PsdFile::from_bytes(&again).unwrap();
assert_eq!(frame_record(&mut f2).block(b"vstk").unwrap().data, first);
// Removing the stroke writes it disabled; no stroke at all writes no `vstk`.
let mut cleared = back.clone();
if let Some(LayerContent::Group(g)) = cleared.layers.iter_mut().map(|l| &mut l.content).find(|c| matches!(c, LayerContent::Group(_))) {
g.frame_stroke = None;
}
let out = export(&cleared, "f.psd", &ExportOptions::default()).unwrap().bytes;
let mut f3 = PsdFile::from_bytes(&out).unwrap();
assert_eq!(parse_vstk(&frame_record(&mut f3).block(b"vstk").unwrap().data, 72.0).unwrap().stroke, None);
let plain = export(&frame_doc(SampleType::U8, None), "f.psd", &ExportOptions::default()).unwrap().bytes;
let mut f4 = PsdFile::from_bytes(&plain).unwrap();
assert!(frame_record(&mut f4).block(b"vstk").is_none());
}
#[test]
fn a_frame_stroke_without_a_vector_mask_or_width_draws_nothing() {
let mut d = frame_doc(SampleType::U8, Some(red_inside(4.0)));
d.layers[1].vector_mask = None;
assert_eq!(px(&d, 21, 30), [1.0, 1.0, 1.0, 1.0]);
let d = frame_doc(SampleType::U8, Some(ShapeStroke { width: 0.0, ..red_inside(4.0) }));
assert_eq!(px(&d, 21, 30), [1.0, 1.0, 1.0, 1.0]);
// Hostile widths from a file render without panicking.
for width in [f32::NAN, f32::INFINITY, -5.0, 1e9] {
let _ = px(&frame_doc(SampleType::U8, Some(ShapeStroke { width, ..red_inside(4.0) })), 21, 30);
}
}
#[test]
fn an_isolated_frame_draws_its_outside_stroke_past_the_outline() {
let mut d = frame_doc(SampleType::U8, Some(ShapeStroke { align: StrokeAlign::Outside, ..red_inside(3.0) }));
d.layers[1].blend = photocraft_color::BlendMode::Normal;
let p = px(&d, 18, 30);
assert!(p[0] > 0.98 && p[1] < 0.02, "{p:?}");
assert!(px(&d, 22, 30)[1] > 0.98, "inside stays white");
}
+60
View File
@@ -328,6 +328,66 @@ fn gzip_compressed_embedded_svg_decompresses_once_with_a_size_cap() {
assert!(r.document.layers.iter().all(|l| !matches!(l.content, LayerContent::Raster(_))), "{:?}", r.document.layers);
}
/// usvg parses an SVG embedded as a `data:` image on its own, past the drawing's depth check:
/// 20 000 nested groups in it overflowed a 2 MB thread stack, an abort nothing can catch. The
/// embedded SVG gets the same check now, so the image is dropped and the drawing opens.
#[test]
fn deeply_nested_embedded_svg_is_dropped_not_a_stack_overflow() {
let deep = nested(20_000);
let percent: String = deep.bytes().map(|b| if b.is_ascii_alphanumeric() { char::from(b).to_string() } else { format!("%{b:02X}") }).collect();
let urls = [
format!("data:image/svg+xml,{percent}"),
base64_data_url("image/svg+xml", deep.as_bytes()),
base64_data_url("text/plain", deep.as_bytes()),
base64_data_url("image/svg+xml", &gzip(deep.as_bytes())),
base64_data_url("text/plain", &gzip(deep.as_bytes())),
];
let embed = |url: &str| format!("<svg {NS} width=\"8\" height=\"8\"><image width=\"8\" height=\"8\" href=\"{url}\"/></svg>");
std::thread::Builder::new()
.stack_size(2 << 20)
.spawn(move || {
for url in &urls {
let r = open(&embed(url));
assert!(r.document.layers.iter().all(|l| !matches!(l.content, LayerContent::Raster(_))), "{}: {:?}", &url[..20], r.document.layers);
}
// Just inside the limit (the drawing's own `<svg>` takes one level) the embedded
// drawing still renders (its rect is black).
let r = open(&embed(&base64_data_url("image/svg+xml", nested(svg::MAX_XML_DEPTH - 2).as_bytes())));
assert!(close(px(&r.document, 4, 4), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&r.document, 4, 4));
})
.unwrap()
.join()
.unwrap();
// A PNG sent as `text/plain` is still an image, not markup to count.
let r = open(&format!(
"<svg {NS} width=\"8\" height=\"8\"><image width=\"8\" height=\"8\" image-rendering=\"pixelated\" href=\"{}\"/></svg>",
png_data_url().replace("image/png", "text/plain")
));
assert!(close(px(&r.document, 1, 1), [1.0, 0.0, 0.0, 1.0]), "{:?}", px(&r.document, 1, 1));
}
/// The embedded SVG is parsed on top of the drawing's own recursion, so the two depths share
/// [`svg::MAX_XML_DEPTH`]: 511 + 511 groups, each fine alone, overflowed a 1 MB thread stack.
#[test]
fn an_embedded_svg_gets_only_the_nesting_depth_the_drawing_left() {
let embed = |outer: usize, inner: usize| {
let url = base64_data_url("image/svg+xml", nested(inner).as_bytes());
format!("<svg {NS} width=\"8\" height=\"8\">{}<image width=\"8\" height=\"8\" href=\"{url}\"/>{}</svg>", "<g>".repeat(outer), "</g>".repeat(outer))
};
std::thread::Builder::new()
.stack_size(1 << 20)
.spawn(move || {
let r = open(&embed(svg::MAX_XML_DEPTH - 1, svg::MAX_XML_DEPTH - 1));
assert!(px(&r.document, 4, 4)[3] < 0.01, "{:?}", px(&r.document, 4, 4));
// Within the shared budget the embedded drawing renders (its rect is black).
let r = open(&embed(250, 250));
assert!(close(px(&r.document, 4, 4), [0.0, 0.0, 0.0, 1.0]), "{:?}", px(&r.document, 4, 4));
})
.unwrap()
.join()
.unwrap();
}
/// 8 000 nested groups (70 KB) overflowed the XML parser's recursion on a 2 MB thread stack, an
/// abort nothing can catch. It is an error now, found before the parser runs; the thread here
/// has a small stack so a regression aborts the test instead of passing by luck.
+204 -7
View File
@@ -33,6 +33,9 @@ pub struct HistoryState {
pub doc: Arc<Document>,
/// The layers targeted when this state was created.
pub layers: LayerTarget,
/// The layers whose visibility the step that created this state changed, as stepping to this
/// state from the one before it in the list must apply it; see [`History::current_visibility`].
pub visibility: Vec<LayerId>,
/// Stable while the state is held: indices shift when older states fall off, ids don't.
pub id: u64,
}
@@ -52,6 +55,16 @@ pub struct History {
current_label: String,
/// The layers targeted when the current document was created (opened or edited).
current_layers: LayerTarget,
/// [`HistoryState::visibility`] of the current state.
current_visibility: Vec<LayerId>,
/// History Options › Allow Non-Linear History: a step recorded while later states exist
/// (after stepping back) keeps them and goes to the end of the list, instead of discarding
/// them; see [`History::record`] and [`History::remove`].
pub non_linear: bool,
/// The most recent [`History::record`], while it is still the latest change to the list;
/// `None` once anything else changed it. [`History::rollback`] needs it to take the step back
/// exactly.
last_record: Option<LastRecord>,
/// The states History panel › Clear History took off the list, kept so the next undo puts
/// them back (Clear History is itself undoable). Any new step drops them.
cleared: Option<Box<Cleared>>,
@@ -63,6 +76,21 @@ pub struct History {
brush_source: Option<u64>,
}
/// What [`History::rollback`] needs to take the most recent [`History::record`] back.
#[derive(Clone, Debug)]
struct LastRecord {
/// The document it replaced (the state a rollback makes current again).
before: Arc<Document>,
/// How many former redo states it moved onto the undo side (always 0 in linear history).
moved: usize,
/// The states it (and the [`History::trim`] after it) dropped for the state limit or the
/// byte budget, in the order they were taken off, each with its index in the undo list at
/// that moment (not always the front: the History Brush's source is never dropped).
/// Their tiles are no longer counted; they are only held until the next change to the
/// list, so a rollback can put them back where they were.
evicted: Vec<(usize, HistoryState)>,
}
/// The undo and redo states [`History::clear_states`] removed, and the History Brush source set
/// before it.
#[derive(Clone, Debug)]
@@ -91,6 +119,9 @@ impl History {
max_bytes: 0,
current_label: "Open".into(),
current_layers: LayerTarget::default(),
current_visibility: Vec::new(),
non_linear: false,
last_record: None,
cleared: None,
current_id: 0,
next_id: 1,
@@ -100,6 +131,11 @@ impl History {
/// Record that `before` was replaced by a new current document via step `label`, which left
/// `layers` targeted.
///
/// Linear history (the default) discards the redo states, as Photoshop does when you edit
/// after selecting an earlier state. With [`History::non_linear`] they are kept: the list
/// reads `…, before, the former redo states (oldest first), the new step`, so the new step is
/// appended at the end and every earlier state can still be stepped to.
pub fn record(&mut self, label: impl Into<String>, before: Arc<Document>, layers: LayerTarget) {
if self.max_bytes == 0 {
self.tiles = None;
@@ -113,19 +149,36 @@ impl History {
}
let prev_label = std::mem::replace(&mut self.current_label, label.into());
let prev_layers = std::mem::replace(&mut self.current_layers, layers);
let prev_visibility = std::mem::take(&mut self.current_visibility);
let prev_id = std::mem::replace(&mut self.current_id, self.next_id);
self.next_id = self.next_id.wrapping_add(1);
self.undo.push_back(HistoryState { label: prev_label, doc: before, layers: prev_layers, id: prev_id });
self.clear_redo();
self.undo.push_back(HistoryState { label: prev_label, doc: before.clone(), layers: prev_layers, visibility: prev_visibility, id: prev_id });
let mut moved = 0;
if self.non_linear {
// `redo` holds the next state last; the list order is oldest first. Their tiles stay
// counted: they only move from the redo side to the undo side.
while let Some(state) = self.redo.pop() {
// The new step follows the last of them in the list but was made from `before`:
// stepping back to them walks back over their steps.
merge_ids(&mut self.current_visibility, &state.visibility);
self.undo.push_back(state);
moved += 1;
}
} else {
self.clear_redo();
}
let mut evicted = Vec::new();
while self.undo.len() > self.max_states {
// The History Brush's source doesn't fall off (as in Photoshop): the next oldest does.
let pinned = self.max_states > 1 && self.undo.front().is_some_and(|s| Some(s.id) == self.brush_source);
if let Some(state) = self.undo.remove(usize::from(pinned))
&& let Some(tiles) = &mut self.tiles
{
let at = usize::from(pinned);
let Some(state) = self.undo.remove(at) else { break };
if let Some(tiles) = &mut self.tiles {
tiles.remove(&state.doc);
}
evicted.push((at, state));
}
self.last_record = Some(LastRecord { before, moved, evicted });
}
pub fn can_undo(&self) -> bool {
@@ -154,6 +207,21 @@ impl History {
self.current_layers = layers;
}
/// The layers whose visibility the step that produced the current document changed, as the
/// session reported them with [`History::add_current_visibility`]. With History Options ›
/// Make Layer Visibility Changes Undoable off, showing or hiding a layer is not a step, so
/// stepping through history keeps every other layer's visibility as it is; these layers take
/// the visibility of the state stepped to, since a recorded step changed them.
pub fn current_visibility(&self) -> &[LayerId] {
&self.current_visibility
}
/// Note layers whose visibility the current step changed (after [`History::record`], and
/// again when a later change coalesces into the step).
pub fn add_current_visibility(&mut self, ids: &[LayerId]) {
merge_ids(&mut self.current_visibility, ids);
}
/// Undo: `current` becomes redoable; returns the document to make current and the layers it
/// targeted when it was created.
pub fn undo(&mut self, current: Arc<Document>) -> Option<(Arc<Document>, LayerTarget)> {
@@ -167,28 +235,32 @@ impl History {
return Some((current, self.current_layers.clone()));
}
let prev = self.undo.pop_back()?;
self.last_record = None;
if let Some(tiles) = &mut self.tiles {
tiles.remove(&prev.doc);
tiles.add(&current);
}
let label = std::mem::replace(&mut self.current_label, prev.label);
let layers = std::mem::replace(&mut self.current_layers, prev.layers.clone());
let visibility = std::mem::replace(&mut self.current_visibility, prev.visibility);
let id = std::mem::replace(&mut self.current_id, prev.id);
self.redo.push(HistoryState { label, doc: current, layers, id });
self.redo.push(HistoryState { label, doc: current, layers, visibility, id });
Some((prev.doc, prev.layers))
}
/// Redo: the mirror of [`Self::undo`].
pub fn redo(&mut self, current: Arc<Document>) -> Option<(Arc<Document>, LayerTarget)> {
let next = self.redo.pop()?;
self.last_record = None;
if let Some(tiles) = &mut self.tiles {
tiles.remove(&next.doc);
tiles.add(&current);
}
let label = std::mem::replace(&mut self.current_label, next.label);
let layers = std::mem::replace(&mut self.current_layers, next.layers.clone());
let visibility = std::mem::replace(&mut self.current_visibility, next.visibility);
let id = std::mem::replace(&mut self.current_id, next.id);
self.undo.push_back(HistoryState { label, doc: current, layers, id });
self.undo.push_back(HistoryState { label, doc: current, layers, visibility, id });
Some((next.doc, next.layers))
}
@@ -270,6 +342,7 @@ impl History {
/// last step can no longer be undone and its pixels are released.
pub fn purge_last(&mut self) -> bool {
self.clear_redo();
self.last_record = None;
if self.cleared.take().is_some() {
self.tiles = None;
return true;
@@ -278,11 +351,118 @@ impl History {
if let Some(tiles) = &mut self.tiles {
tiles.remove(&state.doc);
}
// The current step now starts where the purged one did.
merge_ids(&mut self.current_visibility, &state.visibility);
true
}
/// Delete the past state at entry `i` (indexed like [`History::entries`]) and nothing else,
/// as the History panel's Delete does in non-linear history. The current state is not held
/// here (the session steps back first) and the oldest state can't be removed this way
/// either, since every later state would lose its starting point in the list. Returns
/// whether a state was removed.
pub fn remove(&mut self, i: usize) -> bool {
if i == 0 {
return false;
}
let Some(state) = self.undo.remove(i) else { return false };
self.last_record = None;
if let Some(tiles) = &mut self.tiles {
tiles.remove(&state.doc);
}
// The state after it now follows the one before it.
match self.undo.get_mut(i) {
Some(next) => merge_ids(&mut next.visibility, &state.visibility),
None => merge_ids(&mut self.current_visibility, &state.visibility),
}
true
}
/// Delete the later (redo) state `j` (0 = the next redo, as [`History::redo_labels`] lists
/// them) and nothing else: the History panel's Delete on a dimmed state in non-linear
/// history. Returns whether a state was removed.
pub fn remove_later(&mut self, j: usize) -> bool {
let Some(k) = self.redo.len().checked_sub(j.saturating_add(1)) else { return false };
let state = self.redo.remove(k);
self.last_record = None;
if let Some(tiles) = &mut self.tiles {
tiles.remove(&state.doc);
}
// The state after it in the list (the one before it in `redo`) now follows the state
// before it.
if let Some(next) = k.checked_sub(1).and_then(|n| self.redo.get_mut(n)) {
merge_ids(&mut next.visibility, &state.visibility);
}
true
}
/// Step back like [`History::undo`] but drop the current document instead of making it
/// redoable: the current state is deleted while the redo states (later states in the
/// list) stay. Returns the document to make current and the layers it targeted.
pub fn drop_current(&mut self) -> Option<(Arc<Document>, LayerTarget)> {
let prev = self.undo.pop_back()?;
self.last_record = None;
if let Some(tiles) = &mut self.tiles {
tiles.remove(&prev.doc);
}
self.current_label = prev.label;
self.current_layers = prev.layers.clone();
self.current_id = prev.id;
let dropped = std::mem::replace(&mut self.current_visibility, prev.visibility);
// The next later state now follows `prev`.
if let Some(next) = self.redo.last_mut() {
merge_ids(&mut next.visibility, &dropped);
}
Some((prev.doc, prev.layers))
}
/// Take back the most recent [`History::record`] exactly, for a compound command whose later
/// part failed: the document it replaced becomes current again, the current document is
/// dropped (nothing to redo), and in non-linear history the states that record moved from
/// the redo side go back there, so the list reads as before the command, with the oldest
/// states the state limit or the byte budget dropped for it back too. Returns `None`
/// (changing nothing) when something else changed the list since that record, e.g. an undo;
/// the caller then falls back to [`History::undo`].
pub fn rollback(&mut self) -> Option<(Arc<Document>, LayerTarget)> {
let held = self.last_record.as_ref()?;
if self.undo.len().saturating_add(held.evicted.len()) <= held.moved {
return None;
}
let LastRecord { moved, evicted, .. } = self.last_record.take()?;
// Put each back where it was taken off, the last one taken off first.
for (at, state) in evicted.into_iter().rev() {
if let Some(tiles) = &mut self.tiles {
tiles.add(&state.doc);
}
self.undo.insert(at.min(self.undo.len()), state);
}
for _ in 0..moved {
// Tiles stay counted: they only move back to the redo side.
let state = self.undo.pop_back()?;
self.redo.push(state);
}
let prev = self.undo.pop_back()?;
if let Some(tiles) = &mut self.tiles {
tiles.remove(&prev.doc);
}
self.current_label = prev.label;
self.current_layers = prev.layers.clone();
self.current_visibility = prev.visibility;
self.current_id = prev.id;
Some((prev.doc, prev.layers))
}
/// The document the most recent [`History::record`] replaced, while [`History::rollback`]
/// can still take that record back.
pub fn recorded_before(&self) -> Option<Arc<Document>> {
self.last_record.as_ref().map(|held| held.before.clone())
}
/// Forget the redo states, e.g. after undoing half of a compound step that failed.
pub fn clear_redo(&mut self) {
if !self.redo.is_empty() {
self.last_record = None;
}
if let Some(tiles) = &mut self.tiles {
for state in &self.redo {
tiles.remove(&state.doc);
@@ -295,6 +475,8 @@ impl History {
/// goes back to the default, the oldest state held (now the current one): a source set on a
/// state the purge removed would otherwise leave no source.
pub fn clear(&mut self) {
self.last_record = None;
self.current_visibility.clear();
self.undo.clear();
self.redo.clear();
self.tiles = None;
@@ -320,6 +502,7 @@ impl History {
let redo = std::mem::take(&mut self.redo);
let brush_source = self.brush_source.take();
self.cleared = Some(Box::new(Cleared { undo, redo, brush_source }));
self.last_record = None;
self.tiles = None;
true
}
@@ -462,6 +645,11 @@ impl History {
} else {
tiles.remove(&state.doc);
dropped += 1;
// Held for a rollback of the step that pushed them out. Taking them off from the
// highest index down keeps each index valid, so a rollback can insert them back.
if let Some(held) = &mut self.last_record {
held.evicted.push((i, state));
}
}
}
*undo = kept;
@@ -469,6 +657,15 @@ impl History {
}
}
/// Add the ids of `from` that `into` lacks.
fn merge_ids(into: &mut Vec<LayerId>, from: &[LayerId]) {
for id in from {
if !into.contains(id) {
into.push(*id);
}
}
}
/// Bytes of the pixel tiles of `doc` (layers, masks, fill caches, smart filter masks, alpha channels, selection) not already in `seen`.
fn tile_bytes(doc: &Document, seen: &mut HashSet<usize>) -> usize {
let mut n = 0usize;
+315
View File
@@ -208,6 +208,321 @@ fn new_edit_clears_redo() {
assert_eq!(h.entries(), vec!["Open".to_string(), "B".to_string()]);
}
/// Open, A, B, C, then two steps back: A is current, B and C are redo states.
fn stepped_back(non_linear: bool) -> (History, Arc<Document>) {
let mut h = History { non_linear, ..History::default() };
let mut cur = Arc::new(base());
for n in ["A", "B", "C"] {
edit(&mut h, &mut cur, n, |d| d.name = n.to_lowercase());
}
cur = h.undo(cur).unwrap().0;
cur = h.undo(cur).unwrap().0;
assert_eq!(cur.name, "a");
(h, cur)
}
#[test]
fn non_linear_history_keeps_later_states_and_appends_the_new_step() {
let (mut h, mut cur) = stepped_back(true);
edit(&mut h, &mut cur, "D", |d| d.name = format!("{}+d", d.name));
assert_eq!(h.entries(), ["Open", "A", "B", "C", "D"], "B and C are kept, D goes to the end");
assert!(!h.can_redo());
assert_eq!(cur.name, "a+d", "D was made from the state selected (A), not from C");
// Stepping back walks the list: C, B, A, Open, each with its own document.
let mut names = Vec::new();
while let Some((d, _)) = h.undo(cur.clone()) {
cur = d;
names.push(cur.name.clone());
}
assert_eq!(names, ["c", "b", "a", "h"]);
// Linear history (the default) discards them.
let (mut h, mut cur) = stepped_back(false);
edit(&mut h, &mut cur, "D", |d| d.name = "d".into());
assert_eq!(h.entries(), ["Open", "A", "D"]);
}
#[test]
fn non_linear_steps_respect_the_state_limit_and_the_tile_counter() {
let mut h = History::new(3);
h.non_linear = true;
h.max_bytes = usize::MAX;
let mut cur = Arc::new(base());
for i in 0..4 {
edit(&mut h, &mut cur, &format!("paint {i}"), |d| {
d.layers[0].surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]);
});
h.trim(&cur);
}
cur = h.undo(cur).unwrap().0;
cur = h.undo(cur).unwrap().0;
edit(&mut h, &mut cur, "branch", |d| d.name = "branch".into());
assert_eq!(h.past_len(), 3, "the oldest states beyond the limit go");
assert_eq!(h.entries(), ["paint 1", "paint 2", "paint 3", "branch"]);
assert_counter(&h, &cur);
}
#[test]
fn remove_deletes_one_past_state_and_drop_current_keeps_the_redo_states() {
let (mut h, _) = stepped_back(true);
assert!(!h.remove(0), "the oldest state stays");
assert!(!h.remove(1), "the current state (A) is not held");
assert!(!h.remove(99));
let (mut h, mut cur) = stepped_back(true);
edit(&mut h, &mut cur, "D", |d| d.name = "d".into());
assert!(h.remove(1));
assert_eq!(h.entries(), ["Open", "B", "C", "D"]);
assert_eq!(h.state(1).unwrap().name, "b", "each state keeps its own document");
// Deleting the current state: the one before becomes current, the later ones stay.
let (mut h, _) = stepped_back(true);
let (d, _) = h.drop_current().unwrap();
assert_eq!(d.name, "h");
assert_eq!(h.entries(), ["Open"]);
assert_eq!(h.redo_labels().collect::<Vec<_>>(), ["B", "C"]);
assert!(h.drop_current().is_none(), "nothing before Open");
// The tile counter follows both.
let mut h = History::new(50);
h.non_linear = true;
h.max_bytes = usize::MAX;
let mut cur = Arc::new(base());
for i in 0..4 {
edit(&mut h, &mut cur, "Paint", |d| {
d.layers[0].surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]);
});
h.trim(&cur);
}
cur = h.undo(cur).unwrap().0;
assert!(h.remove(2));
assert_counter(&h, &cur);
cur = h.drop_current().unwrap().0;
assert_counter(&h, &cur);
}
#[test]
fn remove_later_deletes_one_redo_state() {
let mut h = History::new(50);
h.non_linear = true;
h.max_bytes = usize::MAX;
let mut cur = Arc::new(base());
for (i, n) in ["A", "B", "C"].into_iter().enumerate() {
edit(&mut h, &mut cur, n, |d| {
d.layers[0].surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]);
});
h.trim(&cur);
}
cur = h.undo(cur).unwrap().0;
cur = h.undo(cur).unwrap().0;
assert!(!h.remove_later(2) && !h.remove_later(usize::MAX));
assert!(h.remove_later(0), "B");
assert_eq!(h.redo_labels().collect::<Vec<_>>(), ["C"]);
assert_counter(&h, &cur);
let (c, _) = h.redo(cur).unwrap();
assert_eq!(h.entries(), ["Open", "A", "C"]);
assert_counter(&h, &c);
}
/// Each state keeps the layers whose visibility its own step changed; when a state goes
/// (Purge, a non-linear Delete) or a step is appended after the kept later states, the step
/// that now follows covers the changes of the steps it now spans.
#[test]
fn step_visibility_follows_steps_and_spans_removed_ones() {
let vis = |h: &History| {
let mut v: Vec<u64> = h.current_visibility().iter().map(|l| l.0).collect();
v.sort_unstable();
v
};
// Open, then steps A, B, C, D changing layers 1, 2, 3, 4.
let steps = |n: usize, non_linear: bool| {
let mut h = History { non_linear, ..History::default() };
let mut cur = Arc::new(base());
for (i, label) in ["A", "B", "C", "D"].into_iter().take(n).enumerate() {
edit(&mut h, &mut cur, label, |d| d.name = label.to_lowercase());
h.add_current_visibility(&[LayerId(i as u64 + 1)]);
}
(h, cur)
};
let (mut h, cur) = steps(3, false);
assert_eq!(vis(&h), [3]);
let (cur, _) = h.undo(cur).unwrap();
assert_eq!(vis(&h), [2]);
h.redo(cur).unwrap();
assert_eq!(vis(&h), [3]);
// Purge: C now starts from A.
h.purge_last();
assert_eq!(vis(&h), [2, 3]);
// Non-linear Delete of B (between A and C): C now follows A.
let (mut h, cur) = steps(4, true);
assert!(h.remove(2));
h.undo(cur).unwrap();
assert_eq!(vis(&h), [2, 3]);
// Of the later B (A current): C follows A.
let (mut h, mut cur) = steps(3, true);
cur = h.undo(cur).unwrap().0;
cur = h.undo(cur).unwrap().0;
assert!(h.remove_later(0));
h.redo(cur).unwrap();
assert_eq!(vis(&h), [2, 3]);
// Of the current B (C later): A is current, C follows it.
let (mut h, cur) = steps(3, true);
h.undo(cur).unwrap();
let cur = h.drop_current().unwrap().0;
assert_eq!(vis(&h), [1]);
h.redo(cur).unwrap();
assert_eq!(vis(&h), [2, 3]);
// A step after the kept later states B and C (made from A) follows C in the list.
let (mut h, mut cur) = steps(3, true);
cur = h.undo(cur).unwrap().0;
cur = h.undo(cur).unwrap().0;
edit(&mut h, &mut cur, "D", |d| d.name = "d".into());
h.add_current_visibility(&[LayerId(4)]);
assert_eq!(vis(&h), [2, 3, 4]);
h.rollback().unwrap();
assert_eq!(vis(&h), [1], "rollback restores A's");
h.clear();
assert_eq!(vis(&h), Vec::<u64>::new());
}
#[test]
fn rollback_takes_the_last_record_back_exactly_in_both_modes() {
// Non-linear, with redo states: the moved states go back to the redo side and the
// document the step replaced (A) is current again, not the last former redo state (C).
let (mut h, mut cur) = stepped_back(true);
let a = cur.clone();
let entries = h.entries();
let redo: Vec<String> = h.redo_labels().map(str::to_string).collect();
edit(&mut h, &mut cur, "D", |d| d.name = "d".into());
assert_eq!(h.recorded_before().map(|d| d.name.clone()).as_deref(), Some("a"));
let (d, _) = h.rollback().unwrap();
assert!(Arc::ptr_eq(&d, &a));
assert_eq!(h.entries(), entries);
assert_eq!(h.redo_labels().map(str::to_string).collect::<Vec<_>>(), redo);
assert!(h.rollback().is_none(), "only once");
// Redoing still walks the kept states.
let (b, _) = h.redo(d).unwrap();
assert_eq!(b.name, "b");
// Linear: the same as an undo whose redo state is dropped.
let (mut h, mut cur) = stepped_back(false);
edit(&mut h, &mut cur, "D", |d| d.name = "d".into());
assert_eq!(h.rollback().unwrap().0.name, "a");
assert_eq!(h.entries(), ["Open", "A"]);
assert!(!h.can_redo());
// Anything else since the record: no rollback (the caller undoes instead).
let (mut h, mut cur) = stepped_back(true);
edit(&mut h, &mut cur, "D", |d| d.name = "d".into());
cur = h.undo(cur).unwrap().0;
assert!(h.recorded_before().is_none() && h.rollback().is_none());
h.redo(cur).unwrap();
assert!(h.rollback().is_none());
// The tile counter follows.
let mut h = History::new(50);
h.non_linear = true;
h.max_bytes = usize::MAX;
let mut cur = Arc::new(base());
for i in 0..3 {
edit(&mut h, &mut cur, "Paint", |d| {
d.layers[0].surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]);
});
h.trim(&cur);
}
cur = h.undo(cur).unwrap().0;
cur = h.undo(cur).unwrap().0;
edit(&mut h, &mut cur, "Paint", |d| {
d.layers[0].surface_mut().unwrap().write_pixel(2, 2, &[1.0, 0.0, 0.0, 1.0]);
});
cur = h.rollback().unwrap().0;
assert_counter(&h, &cur);
}
/// A failed compound command whose first step moved more later states than the state limit
/// leaves room for (or whose step the byte budget trimmed for) is still taken back exactly:
/// the states the limit dropped for it come back, as in the list before the command.
#[test]
fn rollback_brings_back_the_states_the_limit_dropped_for_the_step() {
for max in 1..=4 {
let (mut h, mut cur) = stepped_back(true);
h.max_states = max;
let a = cur.clone();
edit(&mut h, &mut cur, "D", |d| d.name = "d".into());
assert!(h.past_len() <= max);
assert_eq!(h.recorded_before().map(|d| d.name.clone()).as_deref(), Some("a"), "max {max}");
let (d, _) = h.rollback().unwrap();
assert!(Arc::ptr_eq(&d, &a), "max {max}: back on A, not on an Undo of C");
assert_eq!(h.entries(), ["Open", "A"], "max {max}");
assert_eq!(h.redo_labels().collect::<Vec<_>>(), ["B", "C"], "max {max}");
assert!(h.rollback().is_none());
}
// Once anything else changed the list, the dropped states stay dropped.
let (mut h, mut cur) = stepped_back(true);
h.max_states = 2;
edit(&mut h, &mut cur, "D", |d| d.name = "d".into());
cur = h.undo(cur).unwrap().0;
h.redo(cur).unwrap();
assert!(h.rollback().is_none());
assert_eq!(h.entries(), ["B", "C", "D"]);
// The byte budget: the trim after the step keeps only its newest state; the rollback
// brings back the rest and the tile counter follows.
let mut h = History { non_linear: true, ..History::default() };
let mut cur = Arc::new(base());
for (i, n) in ["A", "B", "C"].into_iter().enumerate() {
edit(&mut h, &mut cur, n, |d| {
d.layers[0].surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]);
});
}
cur = h.undo(cur).unwrap().0;
cur = h.undo(cur).unwrap().0;
h.max_bytes = 1;
edit(&mut h, &mut cur, "D", |d| {
d.layers[0].surface_mut().unwrap().write_pixel(2, 2, &[1.0, 0.0, 0.0, 1.0]);
});
assert!(h.trim(&cur) > 0);
assert_eq!(h.entries(), ["C", "D"]);
assert_counter(&h, &cur);
cur = h.rollback().unwrap().0;
assert_eq!(h.entries(), ["Open", "A"]);
assert_eq!(h.redo_labels().collect::<Vec<_>>(), ["B", "C"]);
assert_counter(&h, &cur);
}
/// The History Brush's source never falls off, so the limit and the byte budget drop states
/// behind it, not at the front; a rollback still puts each back where it was.
#[test]
fn rollback_puts_states_back_behind_the_pinned_brush_source() {
let (mut h, mut cur) = stepped_back(true);
assert!(h.set_brush_source(0));
h.max_states = 2;
let a = cur.clone();
edit(&mut h, &mut cur, "D", |d| d.name = "d".into());
assert_eq!(h.entries(), ["Open", "C", "D"], "A and B go, the source (Open) stays");
let (d, _) = h.rollback().unwrap();
assert!(Arc::ptr_eq(&d, &a));
assert_eq!(h.entries(), ["Open", "A"]);
assert_eq!(h.redo_labels().collect::<Vec<_>>(), ["B", "C"]);
assert_eq!(h.brush_source(), Some(0));
assert_eq!(h.state(0).unwrap().name, "h");
// The byte budget, with the source on the oldest state.
let mut h = History { non_linear: true, ..History::default() };
let mut cur = Arc::new(base());
for (i, n) in ["A", "B", "C"].into_iter().enumerate() {
edit(&mut h, &mut cur, n, |d| {
d.layers[0].surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]);
});
}
cur = h.undo(cur).unwrap().0;
cur = h.undo(cur).unwrap().0;
assert!(h.set_brush_source(0));
h.max_bytes = 1;
edit(&mut h, &mut cur, "D", |d| {
d.layers[0].surface_mut().unwrap().write_pixel(2, 2, &[1.0, 0.0, 0.0, 1.0]);
});
assert!(h.trim(&cur) > 0);
assert_eq!(h.entries(), ["Open", "C", "D"]);
cur = h.rollback().unwrap().0;
assert_eq!(cur.name, "h");
assert_eq!(h.entries(), ["Open", "A"]);
assert_eq!(h.redo_labels().collect::<Vec<_>>(), ["B", "C"]);
assert_eq!(h.state(0).unwrap().name, "h");
assert_counter(&h, &cur);
}
#[test]
fn max_states_bound() {
let mut h = History::new(3);

Some files were not shown because too many files have changed in this diff Show More