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 }}/fuzz
diff --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 above
diff --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");
