mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 11:46:51 -06:00
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
123 lines
5.4 KiB
Rust
123 lines
5.4 KiB
Rust
//! 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(¶ms, 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, ¶ms, |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, ¶ms, |_, _| 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());
|
||
}
|
||
}
|