collective

Browse / github.com/storytold/photocraft / patch

Fixes: Batch and File › Open formats, Check Spelling playback, History Brush source, printer listing, SVG paste, Liquify re-edit, four flaky tests

Fixes: Batch and File › Open formats, Check Spelling playback, History Brush source, printer listing, SVG paste, Liquify re-edit, four flaky tests This branch combines twelve bug-fix pull requests. Each remains open for individual review. - #9607 makes TCP tests read and write through the same stream, avoiding inherited cloned sockets on Windows. - #9340 gives each render its own timeout in the effect-map deadlock test. - #9123 adds the missing readable formats and aliases to File > Open's filters. - #9097 includes every supported image format in folder batches and saves read-only formats as PSD when Format is Same. - #8732 checks GPU timing spans against submitted command buffers. - #8726 replays recorded spelling changes on the target

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #9629, open)
Base
a85d79167ef95b7348872ff6542e011f15b9ec3b
Size
+1483 −167 in 27 files
Review
pending
Shared
2026-10-28
Id
7fe75d05865586639fa8296a

How it went

No other operator has reported using it yet.

Diff

raw · .github/workflows/ci.yml apps/photocraft/src/services.rs apps/photocraft/src/services/tests.rs crates/algo/src/art_history.rs crates/algo/src/art_history/tests.rs crates/automation/src/rpc/tests.rs crates/compose/src/tests.rs crates/engine/src/cmd/file_cmds/batch_run.rs crates/engine/src/cmd/file_cmds/paths.rs crates/engine/src/cmd/file_cmds/tests.rs crates/engine/src/cmd/layer_multi_cmds/selection.rs crates/engine/src/cmd/print_cmds.rs crates/engine/src/cmd/print_cmds/tests.rs crates/engine/src/cmd/type_spell_cmds.rs crates/engine/src/lib.rs crates/engine/tests/panic_hunt.rs crates/gpu/tests/it/timing.rs crates/io/src/svg.rs crates/io/src/svg/tests.rs crates/ops/src/lib.rs crates/ops/src/tests.rs crates/ui-egui/src/dialogs/file_ui/print.rs crates/ui-egui/src/dialogs/file_ui/tests.rs crates/ui-egui/src/dialogs/liquify_ui.rs crates/ui-egui/src/dialogs/liquify_ui/tests.rs crates/ui-egui/src/jobs_ui/tests.rs crates/ui-egui/tests/it/custom_print_paper.rs

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml--- a/.github/workflows/ci.yml+++ b/.github/workflows/ci.yml@@ -407,6 +407,8 @@ jobs:         with:           persist-credentials: false       - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # nightly+        with:+          toolchain: nightly       - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3         with:           workspaces: crates/${{ matrix.target.crate }}/fuzzdiff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs--- a/apps/photocraft/src/services.rs+++ b/apps/photocraft/src/services.rs@@ -15,13 +15,13 @@ use std::sync::Arc; mod recovery_tests;  /// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and-/// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl), swatches (.aco, .act, .ase) and keyboard shortcut sets-/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.+/// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl), patterns (.pat), swatches (.aco, .act, .ase) and+/// keyboard shortcut sets (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[     "pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",     "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm",     "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "asl", "svg", "svgz", "aco", "act",-    "ase", "kys", "af", "afdesign", "afphoto", "afpub",+    "ase", "kys", "af", "afdesign", "afphoto", "afpub", "apng", "jpe", "jfif", "dib", "icb", "vda", "vst", "pnm", "srf", "sr2", "pat", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; @@ -48,30 +48,31 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[     ("OpenRaster", &["ora"]),     ("Paint.NET", &["pdn"]),     ("SVG", SVG_EXTS),-    ("PNG", &["png"]),-    ("JPEG", &["jpg", "jpeg"]),+    ("PNG", &["png", "apng"]),+    ("JPEG", &["jpg", "jpeg", "jpe", "jfif"]),     ("HEIF", &["heic", "heif", "hif"]),     ("TIFF", &["tif", "tiff"]),     ("WebP", &["webp"]),     ("GIF", &["gif"]),-    ("BMP", &["bmp"]),-    ("Targa", &["tga"]),+    ("BMP", &["bmp", "dib"]),+    ("Targa", &["tga", "icb", "vda", "vst"]),     ("ICO", &["ico"]),     ("QOI", &["qoi"]),     ("PCX", &["pcx"]),     ("DDS", &["dds"]),     ("XPM", &["xpm"]),-    ("Portable Bitmap", &["pbm", "pgm", "ppm", "pam", "pfm"]),+    ("Portable Bitmap", &["pnm", "pbm", "pgm", "ppm", "pam", "pfm"]),     ("OpenEXR", &["exr"]),     ("Radiance HDR", &["hdr"]),     ("FITS", &["fits", "fit", "fts"]),     ("DPX and Cineon", &["dpx", "cin"]),     ("Amiga IFF", &["iff", "lbm", "ilbm"]),     ("DICOM", &["dcm", "dicom", "dc3"]),-    ("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),+    ("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "pef", "orf", "rw2", "raf"]),     ("Brushes", &["abr"]),     ("Gradients", &["grd"]),     ("Styles", &["asl"]),+    ("Patterns", &["pat"]),     ("Swatches", &["aco", "act", "ase"]),     ("Keyboard Shortcuts", &["kys"]), ];@@ -543,6 +544,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 +716,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())),diff --git a/apps/photocraft/src/services/tests.rs b/apps/photocraft/src/services/tests.rs--- a/apps/photocraft/src/services/tests.rs+++ b/apps/photocraft/src/services/tests.rs@@ -169,6 +169,19 @@ fn every_open_format_has_its_own_filter() {     } } +/// File › Open's All Formats offers every extension of each format `photocraft-codecs` reads in+/// this build, every camera raw extension the raw reader recognizes, and every preset file+/// extension File › Open imports except `.psp`, which File › Open reads only when the file is+/// a keyboard shortcut set (Photoshop's `Keyboard Shortcuts.psp`).+#[test]+fn all_formats_lists_every_readable_codec_raw_and_preset_extension() {+    let readable = photocraft_codecs::Format::ALL.into_iter().filter(|f| f.caps().read).flat_map(|f| f.extensions().iter().copied());+    let presets = photocraft_ui_egui::preset_files_ui::PRESET_EXTS.iter().copied().filter(|ext| *ext != "psp");+    for ext in readable.chain(photocraft_io::raw::EXTENSIONS.iter().copied()).chain(presets) {+        assert!(OPEN_EXTS.contains(&ext), ".{ext} is missing from File › Open's All Formats");+    }+}+ #[cfg(not(all(unix, not(target_os = "macos"))))] #[test] fn open_filters_keep_literal_extensions_on_windows_and_macos() {@@ -660,6 +673,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.diff --git a/crates/algo/src/art_history.rs b/crates/algo/src/art_history.rs--- a/crates/algo/src/art_history.rs+++ b/crates/algo/src/art_history.rs@@ -333,10 +333,12 @@ struct SrcView<'a> {     h: usize,     origin: (i32, i32),     ch: usize,+    alpha: Option<usize>, }  fn sample_bilinear(src: SrcView<'_>, sx: f32, sy: f32, out: &mut [f32]) -> bool {     let ch = src.ch;+    let alpha = src.alpha.filter(|&a| a < ch);     if ch == 0 || out.len() < ch {         return false;     }@@ -370,12 +372,19 @@ fn sample_bilinear(src: SrcView<'_>, sx: f32, sy: f32, out: &mut [f32]) -> bool             let (Ok(ux), Ok(uy)) = (usize::try_from(px), usize::try_from(py)) else { continue };             let Some(base) = pixel_index(ux, uy, src.w, src.h, ch) else { continue };             let Some(pxs) = src.data.get(base..base.saturating_add(ch)) else { continue };-            for (d, s) in dst.iter_mut().zip(pxs.iter()) {-                *d += *s * wt;+            let coverage = alpha.and_then(|a| pxs.get(a)).copied().unwrap_or(1.0);+            for (c, (d, s)) in dst.iter_mut().zip(pxs.iter()).enumerate() {+                *d += *s * wt * if alpha.is_some_and(|a| c < a) { coverage } else { 1.0 };             }             any = true;         }     }+    if let Some(a) = alpha {+        let coverage = dst.get(a).copied().unwrap_or(0.0);+        for value in dst.iter_mut().take(a) {+            *value = if coverage > 0.0 { *value / coverage } else { 0.0 };+        }+    }     any } @@ -535,7 +544,7 @@ fn stamp_dab_on_rect(p: &mut StampParams<'_>, dab: &Dab, rect: (i32, i32, i32, i                 continue;             }             let (sx, sy) = rotated_source(dab, x, y);-            if !sample_bilinear(SrcView { data: p.src, w: p.src_w, h: p.src_h, origin: p.src_origin, ch: p.ch }, sx, sy, &mut sampled) {+            if !sample_bilinear(SrcView { data: p.src, w: p.src_w, h: p.src_h, origin: p.src_origin, ch: p.ch, alpha: p.alpha }, sx, sy, &mut sampled) {                 continue;             }             let Some(li) = pixel_index(lx, ly, tw, th, p.ch) else { continue };@@ -585,13 +594,16 @@ fn dab_mean_diff(p: &StampParams<'_>, dab: &Dab) -> Option<f32> {             let Some(di) = origin_index(p.dest_origin, x, y, p.dest_w, p.dest_h, p.ch) else { continue };             let Some(dst) = p.dest.get(di..di.saturating_add(p.ch)) else { continue };             let (sx, sy) = rotated_source(dab, x, y);-            if !sample_bilinear(SrcView { data: p.src, w: p.src_w, h: p.src_h, origin: p.src_origin, ch: p.ch }, sx, sy, &mut sampled) {+            if !sample_bilinear(SrcView { data: p.src, w: p.src_w, h: p.src_h, origin: p.src_origin, ch: p.ch, alpha: p.alpha }, sx, sy, &mut sampled) {                 continue;             }             let nch = p.ch.min(dst.len()).min(sampled.len());             if nch == 0 {                 continue;             }+            if p.alpha.is_some_and(|a| sampled.get(a).is_some_and(|&v| v == 0.0)) {+                continue;+            }             let (mut colour, mut colours, mut alpha) = (0.0f32, 0u32, 0.0f32);             for i in 0..nch {                 let Some((&s, &d)) = sampled.get(i).zip(dst.get(i)) else { continue };diff --git a/crates/algo/src/art_history/tests.rs b/crates/algo/src/art_history/tests.rs--- a/crates/algo/src/art_history/tests.rs+++ b/crates/algo/src/art_history/tests.rs@@ -1,5 +1,96 @@ use super::*; +#[test]+fn tolerance_ignores_fully_transparent_samples() {+    for ch in [2, 4, 5] {+        for hidden in [1.0, 0.0] {+            let src = fill(2, 1, ch, |x, _, c| {+                if c + 1 == ch {+                    if x == 0 { 1.0 } else { 0.0 }+                } else if x == 0 {+                    0.4+                } else {+                    hidden+                }+            });+            let before = fill(2, 1, ch, |x, _, c| {+                if c + 1 == ch {+                    if x == 0 { 1.0 } else { 0.0 }+                } else if x == 0 {+                    0.0+                } else {+                    hidden+                }+            });+            let dab = Dab { x: 1.0, y: 0.5, theta: 0.0, radius: 2.0, pressure: 1.0 };+            for tolerance in [0.6, 0.3] {+                let mut dest = before.clone();+                let written = stamp(&mut StampParams {+                    dest: &mut dest,+                    dest_w: 2,+                    dest_h: 1,+                    dest_origin: (0, 0),+                    src: &src,+                    src_w: 2,+                    src_h: 1,+                    src_origin: (0, 0),+                    ch,+                    dabs: std::slice::from_ref(&dab),+                    hardness: 1.0,+                    opacity: 1.0,+                    flow: 1.0,+                    tolerance,+                    tile: 0,+                    selection: None,+                    lock_transparent: false,+                    alpha: Some(ch - 1),+                })+                .unwrap();+                if tolerance == 0.6 {+                    assert_eq!(written, 0, "channels={ch}, hidden={hidden}");+                    assert_eq!(dest, before);+                } else {+                    assert!(written > 0, "channels={ch}, hidden={hidden}");+                    assert!((dest[0] - 0.4).abs() < 1e-6);+                    assert_eq!(&dest[ch..], &before[ch..]);+                }+            }+        }+    }+}++#[test]+fn rotated_dabs_preserve_color_at_transparent_edges() {+    for ch in [2, 4, 5] {+        for hidden in [0.0, 1.0] {+            let (w, h) = (4, 4);+            let src = fill(w, h, ch, |x, _, c| {+                if c + 1 == ch {+                    if x < 2 { 1.0 } else { 0.0 }+                } else if x < 2 {+                    if c == 0 { 0.8 } else { 0.25 }+                } else {+                    hidden+                }+            });+            let mut dest = vec![0.0; w * h * ch];+            let dab = Dab { x: 2.0, y: 2.0, theta: std::f32::consts::FRAC_PI_4, radius: 4.0, pressure: 1.0 };+            stamp_run(w, h, ch, &src, &mut dest, &dab, 0);+            let mut partial = 0;+            for pixel in dest.chunks_exact(ch) {+                if pixel[ch - 1] > 0.0 && pixel[ch - 1] < 1.0 {+                    partial += 1;+                    for (c, value) in pixel[..ch - 1].iter().enumerate() {+                        let expected = if c == 0 { 0.8 } else { 0.25 };+                        assert!((value - expected).abs() < 1e-6, "channels={ch}, hidden={hidden}, pixel={pixel:?}");+                    }+                }+            }+            assert!(partial > 0);+        }+    }+}+ fn fill(w: usize, h: usize, ch: usize, f: impl Fn(i32, i32, usize) -> f32) -> Vec<f32> {     let mut v = vec![0.0; w * h * ch];     for y in 0..h {diff --git a/crates/automation/src/rpc/tests.rs b/crates/automation/src/rpc/tests.rs--- a/crates/automation/src/rpc/tests.rs+++ b/crates/automation/src/rpc/tests.rs@@ -804,10 +804,10 @@ fn idle_tcp_daemon_releases_its_port_but_keeps_active_clients() {         done_tx.send(result).unwrap();     });     let addr = ready_rx.recv_timeout(Duration::from_secs(2)).unwrap();-    let mut client = std::net::TcpStream::connect(addr).unwrap();+    let client = std::net::TcpStream::connect(addr).unwrap();     client.set_read_timeout(Some(Duration::from_secs(2))).unwrap();-    let mut reader = BufReader::new(client.try_clone().unwrap());-    writeln!(client, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();+    let mut reader = BufReader::new(&client);+    writeln!(&client, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();     let mut line = String::new();     reader.read_line(&mut line).unwrap();     let auth: Value = serde_json::from_str(&line).unwrap();@@ -831,14 +831,14 @@ fn tcp_serves_loopback() {         let _ = serve_tcp("127.0.0.1:0", h, TOKEN.into(), move |a| tx.send(a).unwrap());     });     let addr = rx.recv().unwrap();-    let mut s = std::net::TcpStream::connect(addr).unwrap();-    writeln!(s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();-    let mut reader = BufReader::new(s.try_clone().unwrap());+    let s = std::net::TcpStream::connect(addr).unwrap();+    writeln!(&s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();+    let mut reader = BufReader::new(&s);     let mut line = String::new();     reader.read_line(&mut line).unwrap();     let auth: Value = serde_json::from_str(&line).unwrap();     assert_eq!(auth["result"]["authenticated"], true);-    writeln!(s, r#"{{"id":"a","method":"methods"}}"#).unwrap();+    writeln!(&s, r#"{{"id":"a","method":"methods"}}"#).unwrap();     line.clear();     reader.read_line(&mut line).unwrap();     let v: Value = serde_json::from_str(&line).unwrap();@@ -878,15 +878,17 @@ fn tcp_connections_must_authenticate_before_the_deadline_whatever_they_send() {     drop(slow);      // Authenticates at once, then asks again 300 ms after the deadline.-    let mut s = std::net::TcpStream::connect(addr).unwrap();+    let s = std::net::TcpStream::connect(addr).unwrap();     s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();-    writeln!(s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();-    let mut reader = BufReader::new(s.try_clone().unwrap());+    writeln!(&s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();+    // On Windows a socket from `try_clone` is inherited by processes that other tests start,+    // and the connection then stays open after `s` is dropped. Read and write through `&s`.+    let mut reader = BufReader::new(&s);     let mut line = String::new();     reader.read_line(&mut line).unwrap();     assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["result"]["authenticated"], true);     std::thread::sleep(Duration::from_millis(600));-    writeln!(s, r#"{{"id":"a","method":"methods"}}"#).unwrap();+    writeln!(&s, r#"{{"id":"a","method":"methods"}}"#).unwrap();     line.clear();     reader.read_line(&mut line).unwrap();     assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["id"], "a");@@ -910,11 +912,11 @@ fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() {         let _ = serve_tcp("127.0.0.1:0", h, TOKEN.into(), move |a| tx.send(a).unwrap());     });     let addr = rx.recv().unwrap();-    let mut s = std::net::TcpStream::connect(addr).unwrap();-    let mut reader = BufReader::new(s.try_clone().unwrap());+    let s = std::net::TcpStream::connect(addr).unwrap();+    let mut reader = BufReader::new(&s);     let mut line = String::new();     for id in ["auth", "again"] {-        writeln!(s, r#"{{"id":"{id}","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();+        writeln!(&s, r#"{{"id":"{id}","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();         line.clear();         reader.read_line(&mut line).unwrap();     }@@ -922,7 +924,7 @@ fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() {     assert_eq!(again["id"], "again");     assert_eq!(again["ok"], false);     assert_eq!(again["error"], "already authenticated");-    writeln!(s, r#"{{"id":"m","method":"methods"}}"#).unwrap();+    writeln!(&s, r#"{{"id":"m","method":"methods"}}"#).unwrap();     line.clear();     reader.read_line(&mut line).unwrap();     let v: Value = serde_json::from_str(&line).unwrap();diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs--- a/crates/compose/src/tests.rs+++ b/crates/compose/src/tests.rs@@ -3291,23 +3291,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"),+        }     } } diff --git a/crates/engine/src/cmd/file_cmds/batch_run.rs b/crates/engine/src/cmd/file_cmds/batch_run.rs--- a/crates/engine/src/cmd/file_cmds/batch_run.rs+++ b/crates/engine/src/cmd/file_cmds/batch_run.rs@@ -138,12 +138,16 @@ pub(crate) fn process_tree(  /// The extension a batch output takes with Format "Same": the input's own, except for formats /// PhotoCraft opens but can't write, camera raws above all (#5799: a folder of ARWs failed on-/// every file). Those are saved as PSD, the format Photoshop's Save As offers for a raw. An input-/// whose file name has no extension (a dot in a folder name doesn't count) is saved as PNG.+/// every file), GIMP files (`gz` is the last extension of `.xcf.gz`), Photoshop templates and the+/// codec formats without a writer in this build. Those are saved as PSD, the format Photoshop's+/// Save As offers for a raw. An input whose file name has no extension (a dot in a folder name+/// doesn't count) is saved as PNG. pub(super) fn same_format(path: &str) -> String {-    const IMPORT_ONLY: &[&str] = &["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub", "heic", "heif", "hif", "svg", "svgz", "cin", "pcx", "xpm"];+    const IMPORT_ONLY: &[&str] =+        &["pdn", "kra", "krz", "af", "afdesign", "afphoto", "afpub", "heic", "heif", "hif", "svg", "svgz", "cin", "pcx", "xpm", "xcf", "xcfgz", "gz", "psdt"];     let ext = extension(path).unwrap_or_else(|| "png".into());-    if photocraft_io::raw::EXTENSIONS.contains(&ext.as_str()) || IMPORT_ONLY.contains(&ext.as_str()) { "psd".into() } else { ext }+    let no_writer = photocraft_codecs::from_extension(&ext).is_some_and(|f| !f.caps().write);+    if photocraft_io::raw::EXTENSIONS.contains(&ext.as_str()) || IMPORT_ONLY.contains(&ext.as_str()) || no_writer { "psd".into() } else { ext } }  #[cfg(test)]@@ -155,6 +159,17 @@ fn same_format_takes_the_file_names_extension() {     assert_eq!(same_format("/in/IMG_0001.HEIC"), "psd");     assert_eq!(same_format("/in/logo.svg"), "psd");     assert_eq!(same_format("/in/scan.cin"), "psd");+    // Formats PhotoCraft opens and does not write are saved as PSD.+    for name in ["/in/scan.dcm", "/in/scan.DICOM", "/in/scan.dc3", "/in/page.xcf", "/in/page.xcf.gz", "/in/page.xcfgz", "/in/card.psdt"] {+        assert_eq!(same_format(name), "psd", "{name}");+    }+    for f in photocraft_codecs::Format::ALL.into_iter().filter(|f| f.caps().read && !f.caps().write) {+        for ext in f.extensions() {+            assert_eq!(same_format(&format!("/in/a.{ext}")), "psd", "{f:?} .{ext}");+        }+    }+    assert_eq!(same_format("/in/a.pam"), "pam");+    assert_eq!(same_format("/in/a.dds"), "dds");     // No extension in the file name: PNG, not the path after a dot in a folder name.     assert_eq!(same_format("/in/v1.2/scan"), "png");     assert_eq!(same_format("C:\\in.d\\scan"), "png");diff --git a/crates/engine/src/cmd/file_cmds/paths.rs b/crates/engine/src/cmd/file_cmds/paths.rs--- a/crates/engine/src/cmd/file_cmds/paths.rs+++ b/crates/engine/src/cmd/file_cmds/paths.rs@@ -55,21 +55,22 @@ pub fn list_images(dir: &str) -> Result<Vec<String>> {     Err(EngineError::Other(format!("cannot list {dir}: no file system on the web"))) } -/// Whether a folder entry is one the batch commands open: an `OPENABLE` extension, or GIMP's-/// gzipped `.xcf.gz`, whose last extension alone says nothing.+/// Whether a folder entry is one the batch commands open: an `OPENABLE` extension, an extension+/// of a format `photocraft-codecs` reads in this build, a camera raw extension, or GIMP's gzipped+/// `.xcf.gz`, whose last extension alone says nothing. #[cfg(not(target_arch = "wasm32"))] pub(super) fn openable_name(path: &str) -> bool {     let lower = file_name(path).to_ascii_lowercase();-    lower.ends_with(".xcf.gz") || lower.rsplit_once('.').is_some_and(|(_, ext)| OPENABLE.contains(&ext))+    lower.ends_with(".xcf.gz")+        || lower.rsplit_once('.').is_some_and(|(_, ext)| {+            OPENABLE.contains(&ext) || photocraft_io::raw::EXTENSIONS.contains(&ext) || photocraft_codecs::from_extension(ext).is_some_and(|f| f.caps().read)+        }) } -/// Extensions the batch commands pick up from a folder.+/// Extensions of the document and vector formats the batch commands pick up from a folder:+/// those `photocraft_io::import` reads outside `photocraft-codecs` and the camera raw reader. #[cfg(not(target_arch = "wasm32"))]-const OPENABLE: &[&str] = &[-    "pdn", "ora", "xcf", "xcfgz", "kra", "krz", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi",-    "ico", "pnm", "ppm", "pgm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign",-    "afphoto", "afpub",-];+const OPENABLE: &[&str] = &["pdn", "ora", "xcf", "xcfgz", "kra", "krz", "psd", "psb", "psdt", "pcraft", "svg", "svgz", "af", "afdesign", "afphoto", "afpub"];  pub(crate) fn file_name(path: &str) -> String {     path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()diff --git a/crates/engine/src/cmd/file_cmds/tests.rs b/crates/engine/src/cmd/file_cmds/tests.rs--- a/crates/engine/src/cmd/file_cmds/tests.rs+++ b/crates/engine/src/cmd/file_cmds/tests.rs@@ -1835,6 +1835,50 @@ fn folder_listing_recognises_gzipped_xcf_by_its_double_extension() {     assert!(!super::openable_name("notes.txt")); } +/// A folder batch picks up every extension that the codecs and the camera raw reader open:+/// Cineon, DICOM, Amiga IFF, PCX, XPM, DDS, the Netpbm family, Photoshop templates and the CR3,+/// ORF, RW2, RAF, SRF and SR2 raws.+#[cfg(not(target_arch = "wasm32"))]+#[test]+fn folder_listing_takes_every_format_photocraft_opens() {+    for name in [+        "a.cin", "a.dcm", "a.dicom", "a.dc3", "a.iff", "a.lbm", "a.ilbm", "a.pcx", "a.xpm", "a.dds", "a.pbm", "a.pam", "a.pfm", "a.psdt", "a.cr3", "a.orf",+        "a.rw2", "a.raf", "a.srf", "a.sr2", "A.PCX",+    ] {+        assert!(super::openable_name(name), "{name}");+    }+    for f in photocraft_codecs::Format::ALL.into_iter().filter(|f| f.caps().read) {+        for ext in f.extensions() {+            assert!(super::openable_name(&format!("a.{ext}")), "{f:?} .{ext}");+        }+    }+    for ext in photocraft_io::raw::EXTENSIONS {+        assert!(super::openable_name(&format!("a.{ext}")), ".{ext}");+    }+    // PhotoCraft does not read AVIF.+    assert!(!super::openable_name("a.avif"));+}++/// A batch over a folder processes PAM, PFM and DDS files instead of skipping them.+#[test]+fn batch_folder_processes_netpbm_and_dds_files() {+    let dir = tmp("batch-folder-formats");+    let src = join(&dir, "in");+    std::fs::create_dir_all(&src).unwrap();+    let mut s = Session::new();+    s.execute("file.new", json!({"width": 8, "height": 8, "background": "#ff0000"})).unwrap();+    for name in ["a.pam", "b.pfm", "c.dds"] {+        save_doc(doc(&s), &join(&src, name), None).unwrap();+    }+    std::fs::write(join(&src, "notes.txt"), b"not an image").unwrap();+    let out = join(&dir, "out");+    let r = Session::new().execute("file.automate.batch", json!({"steps": [], "input": src, "output": out, "format": "png"})).unwrap();+    assert!(r["errors"].as_array().unwrap().is_empty(), "{r}");+    let mut files: Vec<String> = r["files"].as_array().unwrap().iter().map(|f| file_name(f.as_str().unwrap())).collect();+    files.sort();+    assert_eq!(files, ["a.png", "b.png", "c.png"], "{r}");+}+ /// The Compression tag (259) of a TIFF's first directory. fn tiff_compression_tag(b: &[u8]) -> Option<u16> {     let le = b.get(..2)? == b"II";diff --git a/crates/engine/src/cmd/layer_multi_cmds/selection.rs b/crates/engine/src/cmd/layer_multi_cmds/selection.rs--- a/crates/engine/src/cmd/layer_multi_cmds/selection.rs+++ b/crates/engine/src/cmd/layer_multi_cmds/selection.rs@@ -131,7 +131,8 @@ pub(super) fn same_named_targets(doc: &photocraft_doc::Document, name: &str, mov /// For `layer.select`, captures the layer by name. For other commands, if a numeric `layer` /// parameter references the document's sole selected layer, normalises it to the target form /// (omitting `layer`) so that the recorded step applies to whatever layer is targeted when-/// replayed or exported to `.atn` (#7248).+/// replayed or exported to `.atn` (#7248). When it drops `layer` from an `edit.checkSpelling` step,+/// it also records `"allLayers": false`. pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Option<&[LayerId]>) -> Value {     if id == "layer.select" {         return recorded_selection(s, p);@@ -148,6 +149,9 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio         let mut p = p;         if let Some(fields) = p.as_object_mut() {             fields.remove("layer");+            if id == "edit.checkSpelling" {+                fields.insert("allLayers".into(), json!(false));+            }         }         return p;     }diff --git a/crates/engine/src/cmd/print_cmds.rs b/crates/engine/src/cmd/print_cmds.rs--- a/crates/engine/src/cmd/print_cmds.rs+++ b/crates/engine/src/cmd/print_cmds.rs@@ -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()) {@@ -1099,7 +1267,7 @@ fn print_one_copy(s: &mut Session, p: &Value) -> Result<Value> {     let mut q = last_print(s).unwrap_or_else(|| json!({}));     // A printer remembered from an earlier session may be gone: then the default takes the job,     // as the Print dialog falls back (#7410). A list that can't be read decides nothing.-    if let Ok(listed) = system_printers(s, &q) {+    if let Some(Ok(listed)) = try_with_spooler(|| system_printers(s, &q)) {         let names: Vec<&str> = listed["printers"].as_array().into_iter().flatten().filter_map(|p| p["name"].as_str()).collect();         drop_vanished_printer(&mut q, &names);     }diff --git a/crates/engine/src/cmd/print_cmds/tests.rs b/crates/engine/src/cmd/print_cmds/tests.rs--- a/crates/engine/src/cmd/print_cmds/tests.rs+++ b/crates/engine/src/cmd/print_cmds/tests.rs@@ -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> {@@ -403,6 +436,41 @@ fn a_vanished_remembered_printer_falls_back_to_the_default() {     assert_eq!(q, json!({"printer": "", "paper": "a5"}), "the default printer stays the default"); } +#[test]+fn print_one_copy_keeps_saved_settings_while_the_spooler_is_busy() {+    use std::sync::atomic::{AtomicBool, Ordering};+    for cached in [false, true] {+        let mut s = session("rgb", 8);+        s.printer_source = Some(test_printers());+        s.file_menu.last_print = Some(json!({"printer": "Saved printer", "printerSettings": "0011", "paper": "a5"}));+        if cached {+            s.execute("file.printers", json!({})).unwrap();+        }+        let (held_tx, held) = std::sync::mpsc::channel();+        let (release, released_rx) = std::sync::mpsc::channel::<()>();+        let released = std::sync::Arc::new(AtomicBool::new(false));+        let done = released.clone();+        let holder = std::thread::spawn(move || {+            with_spooler(|| {+                held_tx.send(()).unwrap();+                let _ = released_rx.recv_timeout(std::time::Duration::from_secs(5));+                done.store(true, Ordering::SeqCst);+            });+        });+        held.recv().unwrap();+        let result = s.start("file.printOneCopy", json!({"dryRun": true}));+        let waited = released.load(Ordering::SeqCst);+        let _ = release.send(());+        holder.join().unwrap();+        assert!(!waited, "Print One Copy waited for the spooler; cached={cached}");+        result.unwrap();+        let remembered = last_print(&s).unwrap();+        assert_eq!(remembered["printer"], "Saved printer");+        assert_eq!(remembered["printerSettings"], "0011");+        assert_eq!(remembered["copies"], 1);+    }+}+ #[test] fn print_one_copy_repeats_the_last_settings() {     let mut s = session("rgb", 8);@@ -676,13 +744,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 abovediff --git a/crates/engine/src/cmd/type_spell_cmds.rs b/crates/engine/src/cmd/type_spell_cmds.rs--- a/crates/engine/src/cmd/type_spell_cmds.rs+++ b/crates/engine/src/cmd/type_spell_cmds.rs@@ -117,7 +117,7 @@ fn list(s: &Session, p: &Value) -> Result<Value> { /// Replaces one occurrence (verified to still be `word` when given). fn change(s: &mut Session, p: &Value) -> Result<Value> {     let cmd = "edit.checkSpelling";-    let id = LayerId(p.get("layer").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`change` needs `layer`"))?);+    let id = layer_param(s, p)?;     let start = p.get("start").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`change` needs `start`"))? as usize;     let end = p.get("end").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`change` needs `end`"))? as usize;     let to = p.get("replace").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`change` needs `replace`"))?.to_string();@@ -270,7 +270,7 @@ pub fn specs() -> Vec<CommandSpec> {             label: "Check Spelling…",             menu: &["Edit"],             shortcut: None,-            params: r#"{"action":"list|change|changeAll|addToDictionary|removeFromDictionary|suggest"="list","allLayers":bool=true,"layer":id? (check one layer),"ignore":[word]? (Ignore All for this check),"suggestions":n=5; change: "layer","start","end" (chars),"word"? (verified),"replace"; changeAll: "word","replace"; addToDictionary/removeFromDictionary/suggest: "word"} → list: {"misspellings":[{"layer","start","end","word","suggestions"}],"count"}"#,+            params: r#"{"action":"list|change|changeAll|addToDictionary|removeFromDictionary|suggest"="list","allLayers":bool=true,"layer":id? (check one layer),"ignore":[word]? (Ignore All for this check),"suggestions":n=5; change: "layer"? (default: the active layer),"start","end" (chars),"word"? (verified),"replace"; changeAll: "word","replace"; addToDictionary/removeFromDictionary/suggest: "word"} → list: {"misspellings":[{"layer","start","end","word","suggestions"}],"count"}"#,             enabled: has_type,             journal: true,             run: check_spelling,@@ -369,6 +369,47 @@ mod tests {         assert_eq!(r["correct"], false);     } +    #[test]+    fn change_without_layer_edits_the_active_type_layer() {+        let (mut s, id) = session("Teh end");+        let r = s.execute("edit.checkSpelling", json!({"action": "change", "start": 0, "end": 3, "word": "Teh", "replace": "The"})).unwrap();+        assert_eq!((r["layer"].as_u64(), r["end"].as_u64()), (Some(id), Some(3)));+        assert_eq!(text_of(&s, id), "The end");+    }++    #[test]+    fn recorded_change_on_the_selected_layer_plays_on_another_document() {+        let (mut s, id) = session("Teh end");+        s.execute("actions.record", json!({"name": "Spell"})).unwrap();+        s.execute("edit.checkSpelling", json!({"action": "change", "layer": id, "start": 0, "end": 3, "word": "Teh", "replace": "The"})).unwrap();+        s.execute("actions.stop", json!({})).unwrap();+        assert_eq!(text_of(&s, id), "The end");+        // The recorded step names no layer, so it acts on the layer targeted when it plays.+        assert!(s.actions.list[0].steps[0].1.get("layer").is_none(), "{:?}", s.actions.list[0].steps);+        s.execute("file.new", json!({"width": 300, "height": 100, "background": "white"})).unwrap();+        let other = s.execute("type.create", json!({"x": 10, "y": 50, "text": "Teh end", "size": 12})).unwrap()["layer"].as_u64().unwrap();+        let r = s.execute("actions.play", json!({"action": "Spell"})).unwrap();+        assert!(r.get("failed").is_none(), "{r}");+        assert_eq!(r["ran"], 1);+        assert_eq!(text_of(&s, other), "The end");+    }++    #[test]+    fn recorded_change_all_on_the_selected_layer_plays_on_that_layer_only() {+        let (mut s, a) = session("teh one");+        let b = s.execute("type.create", json!({"x": 10, "y": 90, "text": "teh two", "size": 12})).unwrap()["layer"].as_u64().unwrap();+        s.execute("layer.select", json!({"layer": a})).unwrap();+        s.execute("actions.record", json!({"name": "All"})).unwrap();+        s.execute("edit.checkSpelling", json!({"action": "changeAll", "layer": a, "word": "teh", "replace": "the"})).unwrap();+        s.execute("actions.stop", json!({})).unwrap();+        assert_eq!((text_of(&s, a).as_str(), text_of(&s, b).as_str()), ("the one", "teh two"));+        s.execute("edit.undo", json!({})).unwrap();+        s.execute("layer.select", json!({"layer": a})).unwrap();+        let r = s.execute("actions.play", json!({"action": "All"})).unwrap();+        assert!(r.get("failed").is_none(), "{r}");+        assert_eq!((text_of(&s, a).as_str(), text_of(&s, b).as_str()), ("the one", "teh two"));+    }+     #[test]     fn disabled_without_type() {         let mut s = Session::new();diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -241,6 +241,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,diff --git a/crates/engine/tests/panic_hunt.rs b/crates/engine/tests/panic_hunt.rs--- a/crates/engine/tests/panic_hunt.rs+++ b/crates/engine/tests/panic_hunt.rs@@ -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();diff --git a/crates/gpu/tests/it/timing.rs b/crates/gpu/tests/it/timing.rs--- a/crates/gpu/tests/it/timing.rs+++ b/crates/gpu/tests/it/timing.rs@@ -17,7 +17,7 @@ use std::time::Instant; use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; use photocraft_doc::{Document, Layer, Size}; use photocraft_geom::Rect;-use photocraft_gpu::{Compositor, DeviceHealth, GpuTiming, timing};+use photocraft_gpu::{Compositor, DeviceHealth, GpuTiming, Stats, timing};  /// Pixels in `r`, as the compositor counts them. fn area(r: Rect) -> u64 {@@ -92,8 +92,14 @@ fn document(size: Size) -> Document {  /// Render `region` and return (submit ms, waited-for wall ms, the measurement). fn measure(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, doc: &Document, region: Rect) -> (f64, f64, Option<GpuTiming>) {+    let (submit_ms, wall_ms, timing, _) = measure_with_stats(device, queue, comp, doc, region);+    (submit_ms, wall_ms, timing)+}++/// [`measure`], also returning the render's [`Stats`].+fn measure_with_stats(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, doc: &Document, region: Rect) -> (f64, f64, Option<GpuTiming>, Stats) {     let t0 = Instant::now();-    comp.render(device, queue, doc, region, |_, _| {}).expect("the document composites on the GPU");+    let stats = comp.render(device, queue, doc, region, |_, _| {}).expect("the document composites on the GPU");     let submit_ms = t0.elapsed().as_secs_f64() * 1000.0;     let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });     let wall_ms = t0.elapsed().as_secs_f64() * 1000.0;@@ -106,7 +112,7 @@ fn measure(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, do         }         let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });     }-    (submit_ms, wall_ms, timing)+    (submit_ms, wall_ms, timing, stats) }  #[test]@@ -189,26 +195,97 @@ fn a_refresh_that_submits_several_times_is_one_measurement() {     let full = Rect::from_xywh(0, 0, size.width, size.height);      let _ = measure(&device, &queue, &mut comp, &doc, full);-    let (submit_ms, wall_ms, timing) = measure(&device, &queue, &mut comp, &doc, full);+    let (submit_ms, wall_ms, timing, stats) = measure_with_stats(&device, &queue, &mut comp, &doc, full);     let t = timing.expect("a batched refresh is measured like any other");-    eprintln!("batched: {} command buffers, gpu {:.1} ms, submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms", t.spans, t.ms);+    eprintln!(+        "batched: {} flushes, {} command buffers timed, gpu {:.1} ms, submit {submit_ms:.1} ms, waited wall {wall_ms:.1} ms",+        stats.flushes, t.spans, t.ms+    );     assert_eq!(t.px, area(full));-    if t.spans < 2 {-        eprintln!("SKIP the sum: this refresh still fit one command buffer");-        return;-    }+    assert!(stats.flushes >= 1, "a refresh over a 24 MiB budget submits in batches");+    // Each flush closes the span of the command buffer it submits and opens one in the next, so the+    // one measurement holds a span for every command buffer the refresh submitted.+    assert_eq!(t.spans as usize, stats.flushes + 1, "the measurement spans every command buffer of the refresh");     assert!(t.ms <= wall_ms * 1.25, "summed gpu time {:.1} ms exceeds the whole waited-for refresh {wall_ms:.1} ms", t.ms);-    // What this bound excludes is summing only the last command buffer. A correct sum measures-    // ~85 % of the waited-for GPU time here; the flush thresholds split on accumulated work, so-    // no single buffer of the several carries much over half of it, and timing only the last-    // would land near 40 %. 0.6 sits between the two with room on both sides for a machine-    // whose split is less even than this one's.-    assert!(-        t.ms >= (wall_ms - submit_ms) * 0.6,-        "summed gpu time {:.1} ms is far below the {:.1} ms the refresh waited: is only the last command buffer timed?",-        t.ms,-        wall_ms - submit_ms-    );+}++/// Submit `buffers` command buffers as one measurement, each copying `src` into `dst` `copies`+/// times, through `start`, `split` and `finish` as a refresh that flushes does.+fn time_copies(+    device: &wgpu::Device,+    queue: &wgpu::Queue,+    timer: &mut timing::GpuTimer,+    src: &wgpu::Buffer,+    dst: &wgpu::Buffer,+    buffers: usize,+    copies: u32,+) -> GpuTiming {+    let mut encoder = device.create_command_encoder(&Default::default());+    timer.start(&mut encoder, buffers as u64);+    for i in 0..buffers {+        for _ in 0..copies {+            encoder.copy_buffer_to_buffer(src, 0, dst, 0, src.size());+        }+        if i + 1 < buffers {+            let mut next = device.create_command_encoder(&Default::default());+            timer.split(&mut encoder, &mut next);+            queue.submit([std::mem::replace(&mut encoder, next).finish()]);+        }+    }+    timer.finish(&mut encoder);+    queue.submit([encoder.finish()]);+    timer.submitted();+    let mut timing = None;+    for _ in 0..8 {+        let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });+        timing = timer.take();+        if timing.is_some() {+            break;+        }+    }+    let t = timing.expect("the copies are measured");+    assert_eq!(t.spans as usize, buffers, "one span per command buffer");+    t+}++/// The measurement of several command buffers is the sum of their spans. This compares GPU time+/// with GPU time only: the same copies timed as one command buffer, and four times over in four.+#[test]+fn several_command_buffers_sum_their_gpu_time() {+    let _lock = crate::GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);+    let Some((device, queue, _)) = gpu() else { return };+    let mut timer = timing::GpuTimer::new(&device, &queue).expect("the device has timestamp queries");+    let buffer = |label| {+        device.create_buffer(&wgpu::BufferDescriptor {+            label: Some(label),+            size: 64 << 20,+            usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,+            mapped_at_creation: false,+        })+    };+    let (src, dst) = (buffer("timing_src"), buffer("timing_dst"));++    // Double the copies until one command buffer takes at least 2 ms.+    let mut copies = 1;+    loop {+        let t = time_copies(&device, &queue, &mut timer, &src, &dst, 1, copies);+        if t.ms >= 2.0 {+            break;+        }+        if copies >= 1024 {+            eprintln!("SKIP the sum: {copies} copies of 64 MiB take {:.2} ms in one command buffer", t.ms);+            return;+        }+        copies *= 2;+    }++    let before = time_copies(&device, &queue, &mut timer, &src, &dst, 1, copies);+    let four = time_copies(&device, &queue, &mut timer, &src, &dst, 4, copies);+    let after = time_copies(&device, &queue, &mut timer, &src, &dst, 1, copies);+    let one = before.ms.max(after.ms);+    eprintln!("{copies} copies: one command buffer {:.2} / {:.2} ms, four {:.2} ms ({:.2}x)", before.ms, after.ms, four.ms, four.ms / one);+    // A sum of the four spans reads about 4x; timing only the last command buffer reads about 1x.+    assert!(four.ms >= 2.5 * one, "four command buffers measured {:.2} ms against {one:.2} ms for one: is only one span counted?", four.ms); }  #[test]diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs--- a/crates/io/src/svg.rs+++ b/crates/io/src/svg.rs@@ -498,14 +498,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`].diff --git a/crates/io/src/svg/tests.rs b/crates/io/src/svg/tests.rs--- a/crates/io/src/svg/tests.rs+++ b/crates/io/src/svg/tests.rs@@ -414,6 +414,28 @@ 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()));diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs--- a/crates/ops/src/lib.rs+++ b/crates/ops/src/lib.rs@@ -402,10 +402,10 @@ impl History {     /// beyond it unseen (#4530). The most recent state is still always kept.     pub fn trim_with(&mut self, current: &Document, pinned: &[&Document]) -> usize {         // While Clear History can be undone, the cleared states are what history holds: the-        // oldest of them go first, as the list's would.-        let (undo, redo) = match &mut self.cleared {-            Some(c) => (&mut c.undo, &c.redo),-            None => (&mut self.undo, &self.redo),+        // oldest of them go first, as the list's would. The source set before it stays pinned.+        let (undo, redo, brush_source) = match &mut self.cleared {+            Some(c) => (&mut c.undo, &c.redo, c.brush_source),+            None => (&mut self.undo, &self.redo, self.brush_source),         };         if self.max_bytes == 0 || undo.len() <= 1 {             return 0;@@ -439,7 +439,7 @@ impl History {         }         // The History Brush's source is kept whatever its age, so its own tiles come first; the         // newest states then fill what remains of the budget (the most recent one always).-        let source = self.brush_source.and_then(|id| undo.iter().position(|s| s.id == id));+        let source = brush_source.and_then(|id| undo.iter().position(|s| s.id == id));         if let Some(state) = source.and_then(|i| undo.get(i)) {             total = total.saturating_add(tile_bytes(&state.doc, &mut seen));         }diff --git a/crates/ops/src/tests.rs b/crates/ops/src/tests.rs--- a/crates/ops/src/tests.rs+++ b/crates/ops/src/tests.rs@@ -197,6 +197,31 @@ fn clear_states_resets_the_brush_source_until_undo() {     assert_eq!((h.brush_source(), h.row_doc(1, &cur).unwrap().name.as_str()), (Some(1), "a")); } +/// The byte budget keeps the source set before Clear History among the cleared states, so+/// Undo Clear History brings it back as the History Brush's source.+#[test]+fn byte_budget_keeps_the_brush_source_of_the_cleared_states() {+    let mut h = History::new(50);+    let mut cur = Arc::new(base());+    for i in 0..5 {+        edit(&mut h, &mut cur, &format!("paint {i}"), |d| {+            let id = d.layers[0].id;+            d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]);+        });+    }+    assert!(h.set_brush_source(1));+    let source = h.state(1).unwrap();+    assert!(h.clear_states());+    h.max_bytes = 1;+    assert_eq!(h.trim(&cur), 3);+    assert_counter(&h, &cur);+    assert!(h.is_cleared() && h.past_len() == 0, "the list stays cleared");+    cur = h.undo(cur).unwrap().0;+    assert_eq!(h.entries(), ["paint 0", "paint 3", "paint 4"], "the source is kept with the last step");+    assert_eq!(h.brush_source(), Some(0));+    assert!(Arc::ptr_eq(&h.row_doc(0, &cur).unwrap(), &source), "the History Brush paints from the same state");+}+ #[test] fn new_edit_clears_redo() {     let mut h = History::default();diff --git a/crates/ui-egui/src/dialogs/file_ui/print.rs b/crates/ui-egui/src/dialogs/file_ui/print.rs--- a/crates/ui-egui/src/dialogs/file_ui/print.rs+++ b/crates/ui-egui/src/dialogs/file_ui/print.rs@@ -31,9 +31,6 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 {     let mut f = Map::new();     f.insert("__print".into(), json!(true));     f.insert("__label".into(), json!("PhotoCraft Print Settings"));-    // Asked once per opening: the Printer menu lists what the spooler knows now.-    let printers = app.session.execute("file.printers", json!({})).unwrap_or_default();-    f.insert("__systemPrinters".into(), printers["printers"].clone());     // The settings of the last print, also from an earlier session (#7410).     if let Some(Value::Object(m)) = photocraft_engine::print_cmds::last_print(&app.session) {         f.extend(m);@@ -63,17 +60,32 @@ pub fn open_print(app: &mut PhotocraftApp) -> u64 {     ] {         f.entry(k.to_string()).or_insert(v);     }-    // Windows: the system's printers, and the paper from the chosen printer's driver settings.-    if let Ok(list) = craft_print::rgb::printers() {+    refresh_printers(app, &mut f);+    app.ui.open_dialog(DialogKind::Command, f)+}++fn refresh_printers(app: &mut PhotocraftApp, f: &mut Map<String, Value>) {+    if photocraft_engine::print_cmds::try_with_spooler(|| take_printer_lists(app, f, true)).is_some() {+        f.remove("__printerListingPending");+    } else {+        take_printer_lists(app, f, false);+        f.insert("__printerListingPending".into(), json!(true));+    }+}++fn take_printer_lists(app: &mut PhotocraftApp, f: &mut Map<String, Value>, fresh: bool) {+    let printers = photocraft_engine::print_cmds::printers_without_waiting(&mut app.session);+    f.insert("__systemPrinters".into(), printers["printers"].clone());+    if let Ok(list) = photocraft_engine::print_cmds::rgb_printers_without_waiting(&app.session) {         f.insert("__printers".into(), json!(list.iter().map(|p| p.name.clone()).collect::<Vec<_>>()));         let known = |n: &str| list.iter().any(|p| p.name == n);-        if !known(&s(&f, "printer", "")) {+        if fresh && !list.is_empty() && !known(&s(f, "printer", "")) {             let fallback = list.iter().find(|p| p.default).or(list.first()).map(|p| p.name.clone()).unwrap_or_default();             f.insert("printer".into(), json!(fallback));             f.remove("printerSettings");+            f.remove("__driverPageInitialized");         }     }-    app.ui.open_dialog(DialogKind::Command, f) }  /// Whether the driver's paper, as `driver_page` took it, is wider than tall: the Orientation@@ -91,11 +103,14 @@ pub(super) fn turn_driver(app: &mut PhotocraftApp, f: &mut Map<String, Value>, l     if let (Some(h), Some(o)) = (f.get("printerSettings").cloned(), q.as_object_mut()) {         o.insert("printerSettings".into(), h);     }-    match app.session.execute("file.print.page", q) {-        Ok(r) => take_turned_page(f, &r),-        Err(e) => {+    match photocraft_engine::print_cmds::try_with_spooler(|| app.session.execute("file.print.page", q)) {+        Some(Ok(r)) => take_turned_page(f, &r),+        Some(Err(e)) => {             f.insert("__pageError".into(), json!(e.to_string()));         }+        None => {+            f.insert("__pageError".into(), json!(photocraft_engine::print_cmds::SPOOLER_BUSY));+        }     } } @@ -115,21 +130,27 @@ pub(super) fn take_turned_page(f: &mut Map<String, Value>, r: &Value) { }  /// Takes the paper from the printer's driver settings (Windows), as Photoshop does.-fn driver_page(f: &mut Map<String, Value>) {+pub(super) fn driver_page(f: &mut Map<String, Value>) -> bool {     let printer = s(f, "printer", "");     let settings = f.get("printerSettings").and_then(Value::as_str).and_then(|h| craft_print::rgb::DevMode::from_hex(h).ok());-    match craft_print::rgb::page(&printer, settings.as_ref()) {-        Ok(pg) => {+    match photocraft_engine::print_cmds::try_with_spooler(|| craft_print::rgb::page(&printer, settings.as_ref())) {+        Some(Ok(pg)) => {             f.insert("paper".into(), json!([pg.paper.0, pg.paper.1]));             f.insert("printable".into(), json!([pg.printable.0, pg.printable.1, pg.printable.2, pg.printable.3]));             f.insert("orientation".into(), json!("portrait"));             f.remove("__pageError");         }-        Err(e) => {+        Some(Err(e)) => {             f.remove("printable");             f.insert("__pageError".into(), json!(e.to_string()));         }+        None => {+            f.insert("__pageError".into(), json!(photocraft_engine::print_cmds::SPOOLER_BUSY));+            f.remove("__driverPageInitialized");+            return false;+        }     }+    true }  /// Points per unit for the Position and Size fields.@@ -280,6 +301,12 @@ pub(super) fn print_preview_image(l: photocraft_engine::print_cmds::PrintLayout, /// Dragging the visible image updates dialog offsets live from a fixed gesture origin; /// the position row stays present while centered so switching to manual cannot move the page. pub(super) fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>, id: u64) {+    if b(f, "__printerListingPending", false) {+        refresh_printers(app, f);+        if b(f, "__printerListingPending", false) {+            ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));+        }+    }     let t = Tokens::get(ui.ctx());     let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return };     // PDF exports and previews retain editable paper on Windows too. Only a real native@@ -292,8 +319,11 @@ pub(super) fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map     };     if printers.is_some() {         if !b(f, "__driverPageInitialized", false) {-            driver_page(f);-            f.insert("__driverPageInitialized".into(), json!(true));+            if driver_page(f) {+                f.insert("__driverPageInitialized".into(), json!(true));+            } else {+                ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));+            }         }     } else {         // PDF output and previews have no driver page: Scale to Fit Media uses the paper.@@ -416,15 +446,18 @@ pub(super) fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map                     if ui.button(tl!("Print Settings…")).clicked() {                         let printer = s(f, "printer", "");                         let current = f.get("printerSettings").and_then(Value::as_str).and_then(|h| craft_print::rgb::DevMode::from_hex(h).ok());-                        match craft_print::rgb::settings_dialog(&printer, current.as_ref(), None) {-                            Ok(Some(dm)) => {+                        match photocraft_engine::print_cmds::try_with_spooler(|| craft_print::rgb::settings_dialog(&printer, current.as_ref(), None)) {+                            Some(Ok(Some(dm))) => {                                 f.insert("printerSettings".into(), json!(dm.to_hex()));                                 driver_page(f);                             }-                            Ok(None) => {}-                            Err(e) => {+                            Some(Ok(None)) => {}+                            Some(Err(e)) => {                                 f.insert("__pageError".into(), json!(e.to_string()));                             }+                            None => {+                                f.insert("__pageError".into(), json!(photocraft_engine::print_cmds::SPOOLER_BUSY));+                            }                         }                     }                 });@@ -631,6 +664,32 @@ pub(super) fn print_confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) ->     Ok(r) } +#[cfg(all(test, windows))]+#[test]+fn printer_retry_invalidates_geometry_only_when_the_printer_changes() {+    let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());+    app.session.printer_source =+        Some(Arc::new(|| vec![craft_print::Printer { name: "Replacement printer".into(), default: true, two_sided: None, description: String::new() }]));+    let mut fields = json!({+        "printer": "Old printer", "printerSettings": "0011",+        "__printerListingPending": true, "__driverPageInitialized": true,+        "paper": [100.0, 200.0], "printable": [1.0, 2.0, 98.0, 196.0]+    })+    .as_object()+    .unwrap()+    .clone();+    photocraft_engine::print_cmds::with_spooler(|| {+        refresh_printers(&mut app, &mut fields);+        assert_eq!(fields["printer"], "Replacement printer");+        assert!(!fields.contains_key("printerSettings"));+        assert!(!fields.contains_key("__printerListingPending"));+        assert!(!fields.contains_key("__driverPageInitialized"));+        fields.insert("__driverPageInitialized".into(), json!(true));+        refresh_printers(&mut app, &mut fields);+        assert_eq!(fields["__driverPageInitialized"], true);+    });+}+ pub(crate) fn print_status(app: &mut PhotocraftApp, r: &Value) {     app.ui.status = if let Some(printer) = r["spooler"]["printer"].as_str() {         crate::i18n::fmt(tl!("Sent to {printer}"), &[("printer", printer)])diff --git a/crates/ui-egui/src/dialogs/file_ui/tests.rs b/crates/ui-egui/src/dialogs/file_ui/tests.rs--- a/crates/ui-egui/src/dialogs/file_ui/tests.rs+++ b/crates/ui-egui/src/dialogs/file_ui/tests.rs@@ -1,5 +1,177 @@ use super::*; +#[test]+fn busy_driver_page_requests_a_retry_without_input() {+    let (mut app, ctx) = app();+    let mut fields = json!({"printer": "Test printer", "__printers": ["Test printer"], "paper": "letter"}).as_object().unwrap().clone();+    let (held_tx, held) = std::sync::mpsc::channel();+    let (release, released_rx) = std::sync::mpsc::channel::<()>();+    let holder = std::thread::spawn(move || {+        photocraft_engine::print_cmds::with_spooler(|| {+            held_tx.send(()).unwrap();+            let _ = released_rx.recv_timeout(std::time::Duration::from_secs(5));+        });+    });+    held.recv().unwrap();+    let mut delay = std::time::Duration::MAX;+    for _ in 0..6 {+        let mut output = ctx.run_ui(Default::default(), |ui| print_body(&mut app, ui, &mut fields, 1));+        delay = output.viewport_output[&egui::ViewportId::ROOT].repaint_delay;+        output.textures_delta.clear();+    }+    let busy = fields.get("__pageError").cloned();+    let initialized = fields.get("__driverPageInitialized").cloned();+    let _ = release.send(());+    holder.join().unwrap();+    assert_eq!(busy, Some(json!(photocraft_engine::print_cmds::SPOOLER_BUSY)));+    assert_ne!(initialized, Some(json!(true)));+    assert!(delay <= std::time::Duration::from_millis(100), "busy driver repaint delay: {delay:?}");+    photocraft_engine::print_cmds::with_spooler(|| {+        ctx.run_ui(Default::default(), |ui| print_body(&mut app, ui, &mut fields, 1)).textures_delta.clear();+        assert_eq!(fields.get("__driverPageInitialized"), Some(&json!(true)));+        assert_ne!(fields.get("__pageError"), Some(&json!(photocraft_engine::print_cmds::SPOOLER_BUSY)));+    });+}++#[cfg(windows)]+#[test]+fn print_dialog_keeps_saved_settings_when_the_cached_printer_list_is_empty() {+    check_saved_settings_with_cached_printers(false);+}++#[cfg(windows)]+#[test]+fn print_dialog_keeps_saved_settings_when_the_cached_printer_list_is_stale() {+    check_saved_settings_with_cached_printers(true);+}++#[cfg(windows)]+fn check_saved_settings_with_cached_printers(stale: bool) {+    use std::sync::atomic::{AtomicBool, Ordering};+    let (mut app, ctx) = app();+    let available = Arc::new(AtomicBool::new(false));+    let listed = Arc::clone(&available);+    app.session.printer_source = Some(Arc::new(move || {+        if listed.load(Ordering::SeqCst) {+            vec![craft_print::Printer { name: "Saved printer".into(), default: true, two_sided: None, description: String::new() }]+        } else if stale {+            vec![craft_print::Printer { name: "Old printer".into(), default: true, two_sided: None, description: String::new() }]+        } else {+            Vec::new()+        }+    }));+    app.session.prefs.edit(|p| {+        p.dialogs.insert(photocraft_engine::print_cmds::REMEMBERED.into(), json!({"printer": "Saved printer", "printerSettings": "0011"}));+    });+    let mut lister = photocraft_engine::Session::new();+    lister.printer_source = app.session.printer_source.clone();+    let (held_tx, held) = std::sync::mpsc::channel();+    let (release, released_rx) = std::sync::mpsc::channel::<()>();+    let holder = std::thread::spawn(move || {+        photocraft_engine::print_cmds::with_spooler(|| {+            assert_eq!(photocraft_engine::print_cmds::rgb_printers(&lister).unwrap().len(), usize::from(stale));+            available.store(true, Ordering::SeqCst);+            held_tx.send(()).unwrap();+            let _ = released_rx.recv_timeout(std::time::Duration::from_secs(5));+        });+    });+    held.recv().unwrap();+    let id = open_print(&mut app);+    let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone();+    let before_retry = (fields.get("printer").cloned(), fields.get("printerSettings").cloned());+    let pending = fields.get("__printerListingPending").cloned();+    let _ = release.send(());+    holder.join().unwrap();+    assert_eq!(pending, Some(json!(true)));+    assert_eq!(before_retry, (Some(json!("Saved printer")), Some(json!("0011"))));+    fields.insert("dryRun".into(), json!(true));+    photocraft_engine::print_cmds::with_spooler(|| {+        ctx.run_ui(Default::default(), |ui| print_body(&mut app, ui, &mut fields, id)).textures_delta.clear();+        assert_eq!(fields["printer"], "Saved printer");+        assert_eq!(fields["printerSettings"], "0011");+        assert!(!fields.contains_key("__printerListingPending"));+    });+}++#[test]+fn print_dialog_retries_an_uncached_printer_list_after_the_spooler_is_released() {+    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};+    let (mut app, ctx) = app();+    let asked = Arc::new(AtomicUsize::new(0));+    let counted = Arc::clone(&asked);+    app.session.printer_source = Some(Arc::new(move || {+        counted.fetch_add(1, Ordering::SeqCst);+        vec![craft_print::Printer { name: "Retry printer".into(), default: true, two_sided: None, description: String::new() }]+    }));+    let (held_tx, held) = std::sync::mpsc::channel();+    let (release, released_rx) = std::sync::mpsc::channel::<()>();+    let released = Arc::new(AtomicBool::new(false));+    let done = Arc::clone(&released);+    let holder = std::thread::spawn(move || {+        photocraft_engine::print_cmds::with_spooler(|| {+            held_tx.send(()).unwrap();+            let _ = released_rx.recv_timeout(std::time::Duration::from_secs(5));+            done.store(true, Ordering::SeqCst);+        });+    });+    held.recv().unwrap();+    let id = open_print(&mut app);+    let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone();+    fields.insert("dryRun".into(), json!(true));+    ctx.run_ui(Default::default(), |ui| print_body(&mut app, ui, &mut fields, id)).textures_delta.clear();+    let waited = released.load(Ordering::SeqCst);+    let calls_while_busy = asked.load(Ordering::SeqCst);+    let _ = release.send(());+    holder.join().unwrap();+    assert!(!waited, "opening or drawing Print waited for the spooler");+    assert_eq!(calls_while_busy, 0);+    assert_eq!(fields["__systemPrinters"], json!([]));+    photocraft_engine::print_cmds::with_spooler(|| {+        ctx.run_ui(Default::default(), |ui| print_body(&mut app, ui, &mut fields, id)).textures_delta.clear();+        assert_eq!(fields["__systemPrinters"], json!([{"name": "Retry printer", "default": true}]));+        assert_eq!(fields.get("__printers"), cfg!(windows).then(|| json!(["Retry printer"])).as_ref());+        assert!(!fields.contains_key("__printerListingPending"));+        assert!(asked.load(Ordering::SeqCst) > 0);+    });+}++#[test]+fn driver_calls_do_not_wait_while_the_spooler_is_busy() {+    use std::sync::atomic::{AtomicBool, Ordering};+    let (mut app, _) = app();+    let (held_tx, held) = std::sync::mpsc::channel();+    let (release, released_rx) = std::sync::mpsc::channel::<()>();+    let released = Arc::new(AtomicBool::new(false));+    let holder = {+        let released = Arc::clone(&released);+        std::thread::spawn(move || {+            photocraft_engine::print_cmds::with_spooler(|| {+                held_tx.send(()).unwrap();+                let _ = released_rx.recv_timeout(std::time::Duration::from_secs(5));+                released.store(true, Ordering::SeqCst);+            });+        })+    };+    held.recv().unwrap();+    let mut page = Map::new();+    page.insert("printer".into(), json!("Test printer"));+    page.insert("__driverPageInitialized".into(), json!(true));+    let answered = print::driver_page(&mut page);+    let mut turn = Map::new();+    turn.insert("printer".into(), json!("Test printer"));+    turn.insert("paper".into(), json!([595.28, 841.89]));+    turn_driver(&mut app, &mut turn, true);+    let waited = released.load(Ordering::SeqCst);+    let _ = release.send(());+    holder.join().unwrap();+    assert!(!waited, "a driver call waited for the spooler");+    assert!(!answered);+    assert!(page.get("__driverPageInitialized").is_none());+    assert_eq!(page["__pageError"], photocraft_engine::print_cmds::SPOOLER_BUSY);+    assert_eq!(turn["__pageError"], photocraft_engine::print_cmds::SPOOLER_BUSY);+    assert_eq!((turn.get("paper"), turn.get("printerSettings")), (Some(&json!([595.28, 841.89])), None));+}+ /// Checks no-jump centering and nonaccumulating screen displacement against shared layout /// across fit, percentage scaling, orientation and mirroring combinations. #[test]@@ -59,6 +231,8 @@ fn print_drag_rejects_nonfinite_input_and_bounds_offsets() {  fn app() -> (PhotocraftApp, egui::Context) {     let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());+    app.session.printer_source =+        Some(Arc::new(|| vec![craft_print::Printer { name: "Test printer".into(), default: true, two_sided: None, description: String::new() }]));     app.run("file.new", json!({"width": 120, "height": 80, "name": "web"})).unwrap();     (app, egui::Context::default()) }@@ -432,11 +606,16 @@ fn printer_menu_lists_the_spooler_printers_after_the_default() {     assert_eq!(printer_options(None, "").len(), 1);     // The dialog asks the engine, and the list never reaches file.print's params.     let (mut app, ctx) = app();-    let id = crate::menus::invoke(&mut app, &ctx, "file.print", json!({})).unwrap()["dialog"].as_u64().unwrap();+    let id = photocraft_engine::print_cmds::with_spooler(|| crate::menus::invoke(&mut app, &ctx, "file.print", json!({})).unwrap()["dialog"].as_u64().unwrap());     let f = &app.ui.dialog_mut(id).unwrap().fields;-    assert!(f.get("__systemPrinters").is_some_and(Value::is_array));+    assert_eq!(f["__systemPrinters"], json!([{"name": "Test printer", "default": true}]));+    assert_eq!(f.get("__printers"), cfg!(windows).then(|| json!(["Test printer"])).as_ref());+    if cfg!(windows) {+        assert_eq!(f["printer"], "Test printer");+    }     assert!(params(f).get("__systemPrinters").is_none());     assert!(params(f).get("__printers").is_none());+    assert_eq!(photocraft_engine::print_cmds::system_printer_listings(), 0, "a test asked the system spooler for its printers"); }  /// An output path or a preview typed and confirmed in one frame doesn't take the driver's area.@@ -478,7 +657,10 @@ fn save_as_pdf_browse_fills_the_output_from_a_save_dialog() {     let (show, asked) = crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo(out.clone()))]);     let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| {         PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());-        PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() })+        let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() });+        app.session.printer_source =+            Some(Arc::new(|| vec![craft_print::Printer { name: "Test printer".into(), default: true, two_sided: None, description: String::new() }]));+        app     });     h.state_mut().run("file.new", json!({"width": 120, "height": 80, "name": "poster"})).unwrap();     let id = open_print(h.state_mut());diff --git a/crates/ui-egui/src/dialogs/liquify_ui.rs b/crates/ui-egui/src/dialogs/liquify_ui.rs--- a/crates/ui-egui/src/dialogs/liquify_ui.rs+++ b/crates/ui-egui/src/dialogs/liquify_ui.rs@@ -194,8 +194,9 @@ pub struct LiquifyDialog {     pub dab_ms: f64,     /// CPU time spent refreshing the zoomed-in source-resolution preview.     pub preview_ms: f64,-    /// The smart filter being edited: OK updates it instead of adding a new Liquify filter.-    smart_filter: Option<usize>,+    /// The smart filter being edited, with its `smart_filter_dialog::target`: OK checks the target+    /// and updates that filter instead of adding a new Liquify filter.+    smart_filter: Option<(usize, Value)>, }  fn union(a: Option<[usize; 4]>, b: [usize; 4]) -> Option<[usize; 4]> {@@ -233,7 +234,7 @@ impl LiquifyDialog {             "dabMs": self.dab_ms,             "fullResolutionPreview": self.view_bounds.is_some(),             "previewMs": self.preview_ms,-            "smartFilter": self.smart_filter,+            "smartFilter": self.smart_filter.as_ref().map(|(index, _)| index),         })     } @@ -498,7 +499,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>  /// Re-opens Liquify smart filter `index` of smart object `layer` with its recorded strokes and /// mesh size, previewing on the pixels below it; OK updates that filter-/// (`layer.smartFilter.setParams`).+/// (`layer.smartFilter.setParams`) unless the document or the filter changed in the meantime. pub fn open_smart_filter(app: &mut PhotocraftApp, ctx: &egui::Context, layer: LayerId, index: usize) -> Result<(), String> {     use photocraft_engine::distort_cmds::{LIQUIFY, liquify_params};     let st = app.session.active().ok_or("no document")?;@@ -513,7 +514,8 @@ pub fn open_smart_filter(app: &mut PhotocraftApp, ctx: &egui::Context, layer: La     let surf = photocraft_engine::smart_cmds::render_below_filter(&st.doc, sm, index)         .map_err(|e| e.to_string())?         .ok_or("the smart object's contents are unavailable (missing linked file?)")?;-    open_on(app, ctx, layer, surf, strokes, cell, Some(index))+    let target = crate::smart_filter_dialog::target(app, layer, index)?;+    open_on(app, ctx, layer, surf, strokes, cell, Some((index, target))) }  fn open_on(@@ -523,7 +525,7 @@ fn open_on(     surf: Surface,     strokes: Vec<LiquifyStroke>,     cell: f64,-    smart_filter: Option<usize>,+    smart_filter: Option<(usize, Value)>, ) -> Result<(), String> {     let st = app.session.active().ok_or("no document")?;     let canvas = st.doc.bounds();@@ -593,16 +595,25 @@ pub fn cancel(app: &mut PhotocraftApp) { }  /// OK: runs `filter.liquify` with the recorded strokes (one history step). When the dialog edits-/// a smart filter, OK updates that filter instead (also when Restore All left no strokes). The-/// dialog closes either way. `app.run` shows a failed command's error in the status bar, and-/// `commit` returns it.+/// a smart filter, OK updates that filter instead (also when Restore All left no strokes), after+/// `smart_filter_dialog::validate_target` checks that the document and the filter did not change.+/// The dialog closes either way. The status bar shows the error of a failed check or command+/// (`app.run` shows the latter), and `commit` returns it. pub fn commit(app: &mut PhotocraftApp) -> Result<(), String> {     let Some(mut d) = app.distort.liquify.take() else { return Ok(()) };     remember(app, &d.opts);     d.end();-    if let Some(index) = d.smart_filter {+    if let Some((_, target)) = &d.smart_filter {+        let (layer, index) = match crate::smart_filter_dialog::validate_target(app, target) {+            Ok(edit) => edit,+            Err(e) => {+                app.ui.status = e.clone();+                app.ui.status_error = true;+                return Err(e);+            }+        };         let params = json!({"strokes": d.strokes, "meshSize": d.cell});-        return app.run("layer.smartFilter.setParams", json!({"layer": d.layer.0, "index": index, "params": params})).map(|_| ());+        return app.run("layer.smartFilter.setParams", json!({"layer": layer.0, "index": index, "params": params})).map(|_| ());     }     if d.strokes.is_empty() {         return Ok(());diff --git a/crates/ui-egui/src/dialogs/liquify_ui/tests.rs b/crates/ui-egui/src/dialogs/liquify_ui/tests.rs--- a/crates/ui-egui/src/dialogs/liquify_ui/tests.rs+++ b/crates/ui-egui/src/dialogs/liquify_ui/tests.rs@@ -743,7 +743,7 @@ fn smart_filter_double_click_edits_the_recorded_liquify() {     crate::smart_ui::open_editor(&mut app, &ctx, layer.0, &sm, 0);     assert!(app.ui.dialogs.is_empty(), "no generic parameter form");     let d = app.distort.liquify.as_ref().expect("the Liquify workspace opens");-    assert_eq!((d.smart_filter, d.cell), (Some(0), 3.0));+    assert_eq!((d.smart_filter.as_ref().map(|(index, _)| *index), d.cell), (Some(0), 3.0));     assert_eq!(d.strokes, recorded);     assert_eq!(d.field, LiquifyField::from_strokes(d.canvas, 3.0, &recorded));     // The preview starts from the pixels below the filter (no liquify, no blur).@@ -812,12 +812,37 @@ fn smart_filter_edit_that_cannot_be_applied_reports_the_error() {     let r = control(&mut app, &json!({"commit": true}));     assert!(app.distort.liquify.is_none());     assert!(app.ui.status_error);-    assert!(app.ui.status.contains("no smart filters"), "{}", app.ui.status);+    assert!(app.ui.status.contains("no longer exists"), "{}", app.ui.status);     assert!(smart(&app, layer).smart_filters.is_empty());     assert_eq!(app.session.active().unwrap().history.past_len(), steps);     assert_eq!(r, Err(app.ui.status.clone())); } +/// OK after the stack was reordered while the workspace was open: the filter now at the+/// edited index (the Gaussian Blur) is left alone and the status bar asks to reopen.+#[test]+fn smart_filter_edit_after_the_stack_changed_updates_nothing() {+    let ctx = egui::Context::default();+    let (mut app, layer) = smart_liquify_app();+    open_smart_filter(&mut app, &ctx, layer, 0).unwrap();+    drag(&mut app, 70.0, 84.0);+    app.session.execute("layer.smartFilter.move", json!({"layer": layer.0, "index": 0, "to": 1})).unwrap();+    let before = smart(&app, layer).smart_filters;+    assert_eq!(before[0].command, "filter.blur.gaussianBlur");+    let steps = app.session.active().unwrap().history.past_len();+    let r = control(&mut app, &json!({"commit": true}));+    assert!(app.distort.liquify.is_none());+    assert!(app.ui.status_error);+    assert!(app.ui.status.contains("Reopen its dialog"), "{}", app.ui.status);+    let after = smart(&app, layer).smart_filters;+    assert_eq!(after.len(), before.len());+    for (a, b) in after.iter().zip(&before) {+        assert_eq!((&a.command, &a.params), (&b.command, &b.params));+    }+    assert_eq!(app.session.active().unwrap().history.past_len(), steps);+    assert_eq!(r, Err(app.ui.status.clone()));+}+ /// OK after the layer was deleted: the dialog closes, the status bar shows the error and the /// control channel returns it. #[test]diff --git a/crates/ui-egui/src/jobs_ui/tests.rs b/crates/ui-egui/src/jobs_ui/tests.rs--- a/crates/ui-egui/src/jobs_ui/tests.rs+++ b/crates/ui-egui/src/jobs_ui/tests.rs@@ -808,6 +808,8 @@ fn printing_keeps_drawing_and_reports_only_the_final_spooler_result() {             assert!(h.state().ui.status_error);         }     }+    // The job and its status do not ask the system spooler for its printers.+    assert_eq!(photocraft_engine::print_cmds::system_printer_listings(), 0, "a test asked the system spooler for its printers"); }  fn layer_count(app: &PhotocraftApp) -> usize {diff --git a/crates/ui-egui/tests/it/custom_print_paper.rs b/crates/ui-egui/tests/it/custom_print_paper.rs--- a/crates/ui-egui/tests/it/custom_print_paper.rs+++ b/crates/ui-egui/tests/it/custom_print_paper.rs@@ -6,15 +6,28 @@ use serde_json::{Map, Value, json};  fn app() -> (PhotocraftApp, u64) {     let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), photocraft_ui_egui::Services::default());+    // Print lists this printer and does not ask the spooler.+    app.session.printer_source = Some(std::sync::Arc::new(|| {+        vec![craft_print::Printer { name: "Test native printer".into(), default: true, two_sided: None, description: String::new() }]+    }));     app.run("file.new", json!({"width": 120, "height": 80, "name": "Custom paper"})).unwrap();-    let id = open_print(&mut app);+    let id = photocraft_engine::print_cmds::with_spooler(|| open_print(&mut app));+    assert_no_system_listing();+    let fields = &mut app.ui.dialog_mut(id).unwrap().fields;     // Every UI path in these fixtures is safe even if an unexpected Enter confirms the dialog.-    app.ui.dialog_mut(id).unwrap().fields.insert("dryRun".into(), json!(true));+    fields.insert("dryRun".into(), json!(true));+    // The driver Printer menu lists the stand-in on Windows only, where the GDI route is.+    assert_eq!(fields.get("__printers"), cfg!(windows).then(|| json!(["Test native printer"])).as_ref());     // Exercise PDF paper controls even when native printers are available, on every OS.-    app.ui.dialog_mut(id).unwrap().fields.insert("__printers".into(), json!(["Test native printer"]));+    fields.insert("__printers".into(), json!(["Test native printer"]));     (app, id) } +/// No test in this binary asks the system spooler for its printers.+fn assert_no_system_listing() {+    assert_eq!(photocraft_engine::print_cmds::system_printer_listings(), 0, "a test asked the system spooler for its printers");+}+ fn show(ui: &mut egui::Ui, app: &mut PhotocraftApp) {     if ui.ctx().fonts(|fonts| fonts.families().contains(&egui::FontFamily::Name("medium".into()))) {         photocraft_ui_egui::dialogs::show(app, ui.ctx());@@ -195,6 +208,7 @@ fn custom_paper_ui_edits_reach_preview_and_pdf_and_restore_when_reopened() {     assert!(String::from_utf8_lossy(&pdf).contains("/MediaBox [0 0 666.00 522.00]"));     assert_eq!(h.state().session.file_menu.last_print.as_ref().unwrap()["paper"], json!([522.0, 666.0]));     let id = open_print(h.state_mut());+    assert_no_system_listing();     h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("dryRun".into(), json!(true));     h.run_steps(3);     assert!(shown(&h, "Custom"), "reopening array-valued settings must show Custom");