From ccee4894bdab8f34930f1122aab80259bd98d18c Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 02:28:40 +0200 Subject: [PATCH] Use craft-print for PhotoCraft system printing --- Cargo.lock | 10 +++ Cargo.toml | 1 + crates/engine/Cargo.toml | 4 + crates/engine/src/jobs.rs | 5 ++ crates/engine/src/print_cmds.rs | 102 ++++++++++++++++---------- crates/engine/src/print_cmds/tests.rs | 68 ++++++++++++++++- crates/ui-egui/src/file_ui.rs | 9 ++- crates/ui-egui/src/jobs_ui.rs | 8 +- crates/ui-egui/src/jobs_ui_tests.rs | 41 +++++++++++ docs/development.md | 18 +++++ 10 files changed, 222 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fde4ac01b8..54187a84f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1121,6 +1121,14 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-print" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" +dependencies = [ + "tempfile", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -4348,6 +4356,7 @@ name = "photocraft-engine" version = "0.7.0" dependencies = [ "blake3", + "craft-print", "exr", "flate2", "photocraft-affinity", @@ -4372,6 +4381,7 @@ dependencies = [ "roxmltree", "serde", "serde_json", + "tempfile", "thiserror 2.0.21", "wat", ] diff --git a/Cargo.toml b/Cargo.toml index 7502ff4fd5..1ae1c2f5ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ photocraft-testkit = { path = "crates/testkit" } photocraft-tablet = { path = "crates/tablet" } # external +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index fa15c30986..631befd39b 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -34,6 +34,10 @@ photocraft-plugins = { workspace = true } blake3 = { version = "1.8", default-features = false, features = ["pure"] } photocraft-codecs = { workspace = true } flate2 = { workspace = true } +craft-print = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tempfile = "3" # Photoshop `.kys` shortcut sets (`edit.keyboardShortcuts` `importKys`); already in the tree # through photocraft-ui-egui and usvg. roxmltree = "0.20" diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 15e7387b0e..d0349300c0 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -527,6 +527,11 @@ impl Session { /// stays unchanged. The document is unlocked at once. False if no such job is running. pub fn cancel_job(&mut self, id: JobId) -> bool { let Some(i) = self.jobs.running.iter().position(|r| r.id == id) else { return false }; + // A print worker can already have handed the job to the OS. Discarding its result + // would falsely report cancellation; removal belongs to the system print queue. + if matches!(self.jobs.running[i].command.as_str(), "file.print" | "file.printOneCopy") { + return false; + } let job = self.jobs.running.remove(i); job.ctx.cancel(); self.end_job(&job, JobOutcome::Cancelled); diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 59d6b3e1e8..430fd3bde4 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -4,8 +4,8 @@ //! scale, colour handling with printer profile / intent / black-point compensation, corner and //! centre crop marks, registration marks, description and label) with a small PDF writer //! (ISO 32000: one page, a Flate-compressed image XObject, vector marks, Helvetica text), then -//! hands it to the system spooler with `lp` (CUPS, macOS and Linux). `"dryRun": true` stops -//! before spooling and reports the command line, which is how the tests exercise the path. +//! hands its bytes to `craft-print` (CUPS on macOS/Linux, Windows PDF printing on Windows). +//! `"dryRun": true` retains a diagnostic PDF and reports the backend command without spooling. //! //! Paths to Illustrator writes the document's paths as an Adobe Illustrator 3 compatible //! PostScript file (`m`/`L`/`C` path construction, `n`/`N` unpainted closed/open paths, `*u`/`*U` @@ -330,8 +330,37 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let doc = d.doc.clone(); - let PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down } = layout(&doc, p, cmd)?; - let (img, color) = print_image(&doc, p, cmd)?; + let params = p.clone(); + let worker_params = params.clone(); + let command = cmd.to_string(); + let send = p.get("send").and_then(Value::as_bool).unwrap_or(!p.get("output").and_then(Value::as_str).is_some_and(|v| !v.is_empty())); + if send && !p.get("dryRun").and_then(Value::as_bool).unwrap_or(false) { + // Session::execute remains synchronous; the desktop's Session::start snapshots here + // and runs PDF composition and the potentially slow driver on a worker. + return crate::jobs::run( + s, + "Print", + false, + move |ctx| { + ctx.progress(0.0, "Print"); + render_and_spool(&doc, &worker_params, &command) + }, + move |s, result| { + remember_print(s, ¶ms); + crate::automate_cmds::fire_event(s, "print"); + Ok(result) + }, + ); + } + let result = render_and_spool(&doc, p, cmd)?; + remember_print(s, p); + crate::automate_cmds::fire_event(s, "print"); + Ok(result) +} + +fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { + let PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down } = layout(doc, p, cmd)?; + let (img, color) = print_image(doc, p, cmd)?; let description = p .get("description") .and_then(Value::as_bool) @@ -341,38 +370,35 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { let page = PrintPage { paper: (pw, ph), image: img, rect: (x, y, w, h), marks, description, label, emulsion_down }; let output = p.get("output").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string); let send = p.get("send").and_then(Value::as_bool).unwrap_or(output.is_none()); - let pdf_path = match &output { - Some(o) => o.clone(), - None => { - // Unique per call: concurrent prints of same-named documents must not share a spool file. - static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let tmp = std::env::temp_dir().to_string_lossy().into_owned(); - join(&tmp, &format!("{}-print-{}-{n}.pdf", crate::file_cmds::sanitize(&stem(&doc.name)), std::process::id())) - } - }; - let copies = crate::commands::int(p, "copies").unwrap_or(1).clamp(1, 999); + let copies = crate::commands::int(p, "copies").unwrap_or(1).clamp(1, 999) as u32; let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); let pdf = print_pdf(&page); - write_file(&pdf_path, &pdf)?; - let mut argv: Vec = vec!["lp".into()]; - if let Some(pr) = p.get("printer").and_then(Value::as_str).filter(|v| !v.is_empty()) { - argv.extend(["-d".into(), pr.to_string()]); - } - if copies > 1 { - argv.extend(["-n".into(), copies.to_string()]); - } - argv.extend(["-t".into(), doc.name.clone(), pdf_path.clone()]); + let job = craft_print::Job { + printer: p.get("printer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string), + copies, + title: doc.name.clone(), + // PhotoCraft already placed and scaled the image on its physical sheet. Scaling again + // in the driver would change the preview, print resolution and crop-mark positions. + scaling: craft_print::Scaling::Exact, + ..Default::default() + }; + let command = if send { json!(craft_print::command_preview(&job).map_err(other)?) } else { Value::Null }; + let pdf_path = match output { + Some(path) => { + write_file(&path, &pdf)?; + Some(path) + } + None if dry || !send => Some(diagnostic_pdf(&pdf)?), + None => None, + }; let mut sent = false; let mut spool = Value::Null; if send && !dry { - spool = json!(spool_pdf(&argv)?); + spool = json!(craft_print::submit(&pdf, &job).map_err(other)?); sent = true; } - remember_print(s, p); - crate::automate_cmds::fire_event(s, "print"); Ok( - json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": if send { json!(argv) } else { Value::Null }, "sent": sent, "spooler": spool, "color": color}), + json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "color": color}), ) } @@ -386,17 +412,19 @@ fn remember_print(s: &mut Session, p: &Value) { s.file_menu.last_print = Some(remembered); } +/// A requested preview/export artifact belongs to the caller, unlike a spooler's temporary +/// transport file. Create it exclusively and keep it so the command's `pdf` path remains usable. #[cfg(not(target_arch = "wasm32"))] -fn spool_pdf(argv: &[String]) -> Result { - let out = std::process::Command::new(&argv[0]).args(&argv[1..]).output().map_err(|e| other(format!("could not run `lp` (CUPS): {e}")))?; - if !out.status.success() { - return Err(other(format!("lp failed: {}", String::from_utf8_lossy(&out.stderr).trim()))); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +fn diagnostic_pdf(pdf: &[u8]) -> Result { + let mut file = tempfile::Builder::new().prefix("photocraft-print-").suffix(".pdf").tempfile().map_err(other)?; + file.write_all(pdf).map_err(other)?; + file.flush().map_err(other)?; + let (_, path) = file.keep().map_err(other)?; + Ok(path.to_string_lossy().into_owned()) } #[cfg(target_arch = "wasm32")] -fn spool_pdf(_: &[String]) -> Result { - Err(other("printing needs the desktop app")) +fn diagnostic_pdf(_: &[u8]) -> Result { + Err(other("printing needs the desktop app; specify an output PDF path instead")) } fn print(s: &mut Session, p: &Value) -> Result { @@ -567,7 +595,7 @@ pub fn specs() -> Vec { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } - const PRINT_PARAMS: &str = r##"{"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render the PDF, report the lp command, don't spool)} → {pdf, imageRect, command, sent}"##; + const PRINT_PARAMS: &str = r##"{"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render a diagnostic PDF, report the backend command, don't spool)} → {pdf:path|null (null for spool-only jobs), imageRect, command:diagnostic array|null, sent}"##; vec![ spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print), spec!( diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 877fb945ff..e8e55c8961 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -33,7 +33,7 @@ fn pdf_image(pdf: &[u8]) -> (u32, u32, String, Vec) { } #[test] -fn print_dry_run_renders_a_pdf_and_reports_lp() { +fn print_dry_run_renders_a_pdf_and_reports_the_platform_backend() { let dir = tmp("print"); for depth in [8, 16, 32] { let mut s = session("rgb", depth); @@ -44,7 +44,14 @@ fn print_dry_run_renders_a_pdf_and_reports_lp() { ) .unwrap(); let cmd: Vec = serde_json::from_value(r["command"].clone()).unwrap(); - assert_eq!(&cmd[..5], &["lp", "-d", "Office", "-n", "2"]); + #[cfg(unix)] + { + assert_eq!(&cmd[..5], &["lp", "-d", "Office", "-n", "2"]); + assert!(cmd.contains(&"fit-to-page=false".into())); + assert!(!cmd.contains(&r["pdf"].as_str().unwrap().to_string()), "PDF is streamed, not passed as a file"); + } + #[cfg(windows)] + assert!(cmd.last().unwrap().contains("craft-print PDF spooler")); assert_eq!(r["sent"], false); let pdf = std::fs::read(r["pdf"].as_str().unwrap()).unwrap(); assert!(pdf.starts_with(b"%PDF-1.4")); @@ -152,13 +159,17 @@ fn printer_profile_can_be_an_installed_icc_file() { #[test] fn print_one_copy_repeats_the_last_settings() { let mut s = session("rgb", 8); - s.execute("file.print", json!({"dryRun": true, "copies": 3, "paper": "a5", "scale": 50})).unwrap(); + let first = s.execute("file.print", json!({"dryRun": true, "copies": 3, "paper": "a5", "scale": 50})).unwrap(); + std::fs::remove_file(first["pdf"].as_str().unwrap()).unwrap(); let r = s.execute("file.printOneCopy", json!({"dryRun": true})).unwrap(); assert_eq!(r["copies"], 1); assert_eq!(r["paper"], json!([419.53, 595.28])); assert_eq!(r["scale"], 50.0); let cmd: Vec = serde_json::from_value(r["command"].clone()).unwrap(); - assert!(!cmd.contains(&"-n".to_string())); + #[cfg(unix)] + assert_eq!(&cmd[..3], &["lp", "-n", "1"]); + #[cfg(windows)] + assert!(cmd.last().unwrap().contains("craft-print PDF spooler")); let _ = std::fs::remove_file(r["pdf"].as_str().unwrap()); } @@ -224,3 +235,52 @@ fn paths_export_as_illustrator_postscript() { assert_eq!(r["paths"], 1); assert!(s.execute("file.export.pathsToIllustrator", json!({"paths": "Nope"})).is_err()); } + +/// Launch the command in a subprocess with a fake CUPS binary: the parent process's PATH and +/// parallel tests are untouched, and the real spool boundary still consumes the PDF bytes. +#[cfg(unix)] +#[test] +fn spool_transport_streams_pdf_and_propagates_refusal() { + const CHILD: &str = "PHOTOCRAFT_TEST_PRINT_TRANSPORT"; + if let Ok(dir) = std::env::var(CHILD) { + let mut s = session("rgb", 8); + let crate::jobs::Started::Job(job) = s.start("file.print", json!({"printer": "Office", "copies": 3, "paper": "a4", "scale": 50})).unwrap() else { + panic!("desktop printing must start on a worker"); + }; + assert!(!s.cancel_job(job), "queue side effects must not be reported as cancelled"); + let out = s.wait_job(job).unwrap(); + assert_eq!(out["sent"], true); + assert_eq!(out["spooler"], "request id is Office-42"); + assert!(out["pdf"].is_null(), "spool-only printing leaves no diagnostic PDF"); + let pdf = std::fs::read(format!("{dir}/received.pdf")).unwrap(); + assert!(pdf.starts_with(b"%PDF-1.4")); + assert!(String::from_utf8_lossy(&pdf).contains("/MediaBox [0 0 595.28 841.89]")); + let args = std::fs::read_to_string(format!("{dir}/args")).unwrap(); + assert!(args.contains("fit-to-page=false")); + assert!(args.contains("-n\n3\n")); + assert!(!args.contains(".pdf")); + let remembered = s.file_menu.last_print.clone(); + let explicit = format!("{dir}/saved.pdf"); + let err = s.execute("file.print", json!({"printer": "Refuse", "output": explicit, "send": true})).unwrap_err(); + assert!(err.to_string().contains("queue refused"), "{err}"); + assert!(std::fs::read(&explicit).unwrap().starts_with(b"%PDF-1.4"), "explicit export survives a spool refusal"); + assert_eq!(s.file_menu.last_print, remembered, "failed printing does not replace repeat settings"); + return; + } + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("lp"); + std::fs::write( + &script, + b"#!/bin/sh\nif [ \"$2\" = Refuse ]; then echo 'queue refused' >&2; exit 1; fi\nprintf '%s\\n' \"$@\" > \"$PHOTOCRAFT_TEST_PRINT_TRANSPORT/args\"\n/bin/cat > \"$PHOTOCRAFT_TEST_PRINT_TRANSPORT/received.pdf\"\necho 'request id is Office-42 (0 file(s))'\n", + ).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "print_cmds::tests::spool_transport_streams_pdf_and_propagates_refusal", "--nocapture"]) + .env(CHILD, dir.path()) + .env("PATH", dir.path()) + .output() + .unwrap(); + assert!(result.status.success(), "{}\n{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)); + assert!(String::from_utf8_lossy(&result.stdout).contains("1 passed"), "child must select the transport test"); +} diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 8b96a51a3d..0ac8f98028 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -840,6 +840,13 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> Result { let r = app.run("file.print", params(f))?; + if r["pending"] != json!(true) { + print_status(app, &r); + } + Ok(r) +} + +pub(crate) fn print_status(app: &mut PhotocraftApp, r: &Value) { app.ui.status = if r["sent"] == json!(true) { let template = "Sent to the printer ({spooler})"; crate::i18n::fmt(tl!(template), &[("spooler", r["spooler"].as_str().unwrap_or(""))]) @@ -847,7 +854,7 @@ fn print_confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + crate::file_ui::print_status(app, result); + } JobOutcome::Done(_) => { app.sync_views(); app.ui.status = e.label; @@ -576,12 +579,13 @@ pub fn status_progress(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let (bar_w, bar_h) = if t.pro { (120.0, 6.0) } else { (140.0, 6.0) }; let more = app.session.jobs().len().saturating_sub(1); // Right to left: ×, percentage, bar, label. - let (xr, xresp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + let print = matches!(j.command.as_str(), "file.print" | "file.printOneCopy"); + let (xr, xresp) = ui.allocate_exact_size(vec2(18.0, 18.0), if print { Sense::hover() } else { Sense::click() }); if xresp.hovered() { ui.painter().rect_filled(xr, t.radius_sm, t.hover); } crate::icons::paint(ui, xr, "x", 11.0, if xresp.hovered() { t.text } else { t.text_dim }); - let xresp = xresp.on_hover_text(tl!("Cancel (Esc)")); + let xresp = xresp.on_hover_text(if print { tl!("Manage submitted jobs in the system print queue") } else { tl!("Cancel (Esc)") }); ui.label(RichText::new(percent(&j)).color(t.text_dim).size(11.5).monospace()); let (br, _) = ui.allocate_exact_size(vec2(bar_w, bar_h), Sense::hover()); bar(ui, br, shown_fraction(&j), &t); diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index f0c629105a..975110941c 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -577,3 +577,44 @@ fn a_finished_job_beeps_when_the_preference_is_on() { frames_for(&mut h, 800); assert_eq!(BEEPS.load(Ordering::Relaxed), 1, "one beep for the finished job"); } + +#[test] +fn printing_keeps_drawing_and_reports_only_the_final_spooler_result() { + for success in [true, false] { + let mut h = app_harness(); + let gate = Arc::new(AtomicBool::new(false)); + let worker_gate = gate.clone(); + let Started::Job(id) = h.state_mut().session.start_job( + "file.print", + json!({}), + "Print", + false, + move |_| { + while !worker_gate.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(1)); + } + if success { Ok(json!({"sent": true, "spooler": "request id is Office-42", "pdf": null})) } + else { Err(photocraft_engine::EngineError::Other("queue refused".into())) } + }, + |_, result| Ok(result), + ).unwrap() else { panic!("must run on a worker") }; + let frame = h.state().frame; + h.run_steps(4); + assert!(h.state().frame > frame); + assert!(!h.state().ui.status.contains("Sent to the printer")); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().session.job(id).is_some(), "Esc cannot retract a submitted job"); + gate.store(true, Ordering::Relaxed); + let deadline = Instant::now() + Duration::from_secs(10); + while h.state().session.has_jobs() && Instant::now() < deadline { h.step(); } + assert!(!h.state().session.has_jobs()); + if success { + assert!(h.state().ui.status.contains("Office-42"), "{}", h.state().ui.status); + assert!(!h.state().ui.status_error); + } else { + assert!(h.state().ui.status.contains("queue refused"), "{}", h.state().ui.status); + assert!(h.state().ui.status_error); + } + } +} diff --git a/docs/development.md b/docs/development.md index 6eac94c6ce..567ba9648f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -526,3 +526,21 @@ stroke is drawn above its clipped layers; linked effect patterns tile from the l reference point; stroke distances follow a 5 × 5 chamfer metric (1, √2, √5) seeded at sub-pixel edge offsets; interior effects keep the layer's alpha; outside strokes blend onto the backdrop with their own modes, an upper stroke covering lower ones. + +## System printing + +`file.print` and `file.printOneCopy` keep PDF layout, scaling, marks and ICC conversion in +PhotoCraft and submit the rendered bytes through `craft-print` at the Git revision pinned in +`Cargo.toml`. CUPS streams the PDF on macOS/Linux; Windows uses the shared Windows PDF backend. +The driver receives exact physical sheet size so it does not scale PhotoCraft's layout twice. +Windows raster printing does not establish physical-printer colour fidelity; test critical +photo output with the actual printer and its profile. + +Desktop commands run composition and submission on a background worker. The UI reports success +only when the spooler accepts the job; remove submitted jobs in the system print queue. Headless +`Session::execute` stays synchronous. The returned `pdf` is `null` for a spool-only job, and +`command` is a diagnostic preview, not an executable command with a PDF path. Supplying `output` +keeps a PDF at that path and defaults `send` to false. `dryRun` renders a diagnostic PDF but never +submits; an implicit diagnostic file is exclusively created with a random name and belongs to +the caller, who may delete it after inspection. Shared spooler transport files are automatically +removed on Windows; CUPS needs none.