Browse / github.com/storytold/photocraft / patch
Fixes: Batch and File › Open formats, Check Spelling playback, History Brush source, printer listing, SVG paste, Liquify re-edit, four flaky tests
Fixes: Batch and File › Open formats, Check Spelling playback, History Brush source, printer listing, SVG paste, Liquify re-edit, four flaky tests This branch combines twelve bug-fix pull requests. Each remains open for individual review. - #9607 makes TCP tests read and write through the same stream, avoiding inherited cloned sockets on Windows. - #9340 gives each render its own timeout in the effect-map deadlock test. - #9123 adds the missing readable formats and aliases to File > Open's filters. - #9097 includes every supported image format in folder batches and saves read-only formats as PSD when Format is Same. - #8732 checks GPU timing spans against submitted command buffers. - #8726 replays recorded spelling changes on the target
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #9629, open)
- Base
a85d79167ef95b7348872ff6542e011f15b9ec3b- Size
- +1483 −167 in 27 files
- Review
- pending
- Shared
- 2026-10-28
- Id
7fe75d05865586639fa8296a
How it went
No other operator has reported using it yet.
Diff
raw · .github/workflows/ci.yml apps/photocraft/src/services.rs apps/photocraft/src/services/tests.rs crates/algo/src/art_history.rs crates/algo/src/art_history/tests.rs crates/automation/src/rpc/tests.rs crates/compose/src/tests.rs crates/engine/src/cmd/file_cmds/batch_run.rs crates/engine/src/cmd/file_cmds/paths.rs crates/engine/src/cmd/file_cmds/tests.rs crates/engine/src/cmd/layer_multi_cmds/selection.rs crates/engine/src/cmd/print_cmds.rs crates/engine/src/cmd/print_cmds/tests.rs crates/engine/src/cmd/type_spell_cmds.rs crates/engine/src/lib.rs crates/engine/tests/panic_hunt.rs crates/gpu/tests/it/timing.rs crates/io/src/svg.rs crates/io/src/svg/tests.rs crates/ops/src/lib.rs crates/ops/src/tests.rs crates/ui-egui/src/dialogs/file_ui/print.rs crates/ui-egui/src/dialogs/file_ui/tests.rs crates/ui-egui/src/dialogs/liquify_ui.rs crates/ui-egui/src/dialogs/liquify_ui/tests.rs crates/ui-egui/src/jobs_ui/tests.rs crates/ui-egui/tests/it/custom_print_paper.rs
@@ -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@@ -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())),@@ -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.@@ -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 };@@ -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 {@@ -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();@@ -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"),+ } } } @@ -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");@@ -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()@@ -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";@@ -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; }@@ -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); }@@ -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@@ -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();@@ -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,@@ -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();@@ -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]@@ -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`].@@ -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()));@@ -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)); }@@ -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();@@ -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)])@@ -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());@@ -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(());@@ -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]@@ -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 {@@ -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");