Files
designcraft/crates/engine/src/cmd/print.rs
T
aiongg 978a9b9b98 Merge #452: M6: Adopt craft-print and support Windows PDF printing
Conflict: crates/engine/src/cmd/print.rs: the PR's print_with/craft_print::submit; print_with and the test's expected bytes export through next's interchange::with_pdf_boxes
Conflict: Cargo.lock: next's lock plus only craft-print (the auto-merge had moved windows-sys back to 0.59/0.60 for several crates)
Conflict: Cargo.toml: craft-print entry moved beside craft-ui with a comment
Conflict: ROADMAP.md: status Change texts combined; both progress-log lines (the PR's 2026-10-11 line under the 2026-10-12 ones)
Conflict: docs/gaps.md: status Change texts combined
Conflict: docs/hardware-parity.md: status Change texts combined
2026-10-12 00:21:22 +00:00

123 lines
5.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File › Print: the pages go to a printer through the system print spooler (craft-print), as PDF.
use serde_json::{Value, json};
#[cfg(not(target_arch = "wasm32"))]
use super::str_param;
use super::{CommandSpec, bad, cmd, has_doc};
#[cfg(not(target_arch = "wasm32"))]
use crate::EngineError;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "file.printers", "Printers", [], None, "{} → {printers: [names], default}", super::always, |_, _| Ok(printers())),
cmd!(
noundo "file.print",
"Print…",
["File"],
Some("Cmd+P"),
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}",
has_doc,
print
),
]
}
fn printers() -> Value {
let printers = craft_print::printers();
let default = printers.iter().find(|p| p.default).map(|p| p.name.clone());
let names: Vec<_> = printers.into_iter().map(|p| p.name).collect();
json!({"printers": names, "default": default})
}
fn print(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(target_arch = "wasm32")]
{
super::export::options(p, s.doc()?.doc.page_count())?;
Err(bad("file.print", "printing isn't available on the web: export a PDF and print it"))
}
#[cfg(not(target_arch = "wasm32"))]
print_with(s, p, craft_print::submit)
}
#[cfg(not(target_arch = "wasm32"))]
fn print_with(
s: &mut Session,
p: &Value,
submit: impl FnOnce(&[u8], &craft_print::Job) -> std::result::Result<String, craft_print::Error>,
) -> Result<Value> {
let d = &s.doc()?.doc;
let opts = super::export::options(p, d.page_count())?;
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999) as u32;
let job = craft_print::Job {
printer: str_param(p, "printer").map(str::to_string),
copies,
scaling: craft_print::Scaling::ShrinkToFit,
title: d.title.clone(),
..craft_print::Job::default()
};
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
let pdf = designcraft_pdf::export_pdf(&super::interchange::with_pdf_boxes(d), &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
let command = craft_print::command_preview(&job).map_err(|e| bad("file.print", e.to_string()))?;
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
let out = json!({"printer": job.printer, "copies": copies, "pages": pages, "command": command, "bytes": pdf.len()});
if !dry {
submit(&pdf, &job).map_err(|e| bad("file.print", e.to_string()))?;
}
Ok(out)
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
#[test]
fn print_builds_the_spool_command() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 4})).unwrap();
let r = s.execute("file.print", &json!({"printer": "Office", "copies": 2, "pages": "2-3", "dryRun": true})).unwrap();
assert_eq!(r["pages"], 2);
let command: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
if craft_print::backend() == Some(craft_print::Backend::Cups) {
assert_eq!(&command[..5], ["lp", "-d", "Office", "-n", "2"]);
assert!(command.contains(&"print-scaling=auto-fit"));
assert!(!command.iter().any(|a| a.starts_with("sides=")));
} else {
assert_eq!(command[0], "powershell");
}
assert!(r["bytes"].as_u64().unwrap() > 500);
assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err());
assert!(s.execute("file.printers", &json!({})).unwrap()["printers"].is_array());
}
#[test]
fn print_submits_the_selected_export_and_reports_refusal() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 4})).unwrap();
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true});
let expected = {
let d = &s.doc().unwrap().doc;
let options = super::super::export::options(&params, d.page_count()).unwrap();
designcraft_pdf::export_pdf(&super::super::interchange::with_pdf_boxes(d), &s.cache, &options).unwrap()
};
let r = print_with(&mut s, &params, |pdf, job| {
// The production export options and full bytes reach the shared spooler boundary.
assert_eq!(pdf, expected);
assert!(pdf.starts_with(b"%PDF-"));
assert_eq!(job.printer.as_deref(), Some("Office (east)"));
assert_eq!(job.copies, 2);
assert_eq!(job.duplex, None);
assert_eq!(job.scaling, craft_print::Scaling::ShrinkToFit);
assert!(!job.title.is_empty());
Ok("request id is Office-1".into())
})
.unwrap();
assert_eq!(r["pages"], 2);
let refusal = print_with(&mut s, &params, |_, _| Err(craft_print::Error::Spool("printer offline".into()))).unwrap_err();
assert!(refusal.to_string().contains("printer offline"));
print_with(&mut s, &json!({"dryRun": true}), |_, _| panic!("dry runs must not spool")).unwrap();
assert!(print_with(&mut s, &json!({"pages": "9"}), |_, _| panic!("invalid ranges must not spool")).is_err());
}
}