mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 18:22:00 +00:00
Use craft-print for PhotoCraft system printing
This commit is contained in:
1 parent
c450e755ae
commit
ccee4894bd
10 files changed
+222
-44
No files matched your search
Generated
+10
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<PrintLayout> {
|
||||
fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<String> = 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
Err(other("printing needs the desktop app"))
|
||||
fn diagnostic_pdf(_: &[u8]) -> Result<String> {
|
||||
Err(other("printing needs the desktop app; specify an output PDF path instead"))
|
||||
}
|
||||
|
||||
fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -567,7 +595,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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!(
|
||||
|
||||
@@ -33,7 +33,7 @@ fn pdf_image(pdf: &[u8]) -> (u32, u32, String, Vec<u8>) {
|
||||
}
|
||||
|
||||
#[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<String> = 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<String> = 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");
|
||||
}
|
||||
@@ -840,6 +840,13 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
|
||||
|
||||
fn print_confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
|
||||
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<String, Value>) -> Result<Valu
|
||||
let template = "Printed to {path}";
|
||||
crate::i18n::fmt(tl!(template), &[("path", r["pdf"].as_str().unwrap_or(""))])
|
||||
};
|
||||
Ok(r)
|
||||
app.ui.status_error = false;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -484,6 +484,9 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) {
|
||||
app.ui.panels.brush_settings = true;
|
||||
app.ui.brush_tab = 1;
|
||||
}
|
||||
JobOutcome::Done(ref result) if matches!(e.command.as_str(), "file.print" | "file.printOneCopy") => {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user