mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 06:06:49 -06:00
163 lines
7.3 KiB
Rust
163 lines
7.3 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};
|
||
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\", dryRun?: bool (export and preview submission without printing), …the PDF settings of file.exportPdf (the PDF it writes is what prints)} → {printer, copies, pages, command, bytes, warnings}",
|
||
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 pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
|
||
// The PDF that Export PDF writes with these settings.
|
||
let pdf = super::export::pdf_export(s, p)?;
|
||
let command = craft_print::command_preview(&job).map_err(|e| bad("file.print", e.to_string()))?;
|
||
let out =
|
||
json!({"printer": job.printer, "copies": copies, "pages": pages, "command": command, "bytes": pdf.bytes.len(), "warnings": pdf.warnings});
|
||
if !dry {
|
||
submit(&pdf.bytes, &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,
|
||
[
|
||
"xps-print",
|
||
"printer=Office",
|
||
"copies=2",
|
||
"collate=true",
|
||
"duplex=(queue default)",
|
||
"scaling=shrink-to-fit",
|
||
"color=(driver default)"
|
||
]
|
||
);
|
||
}
|
||
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();
|
||
// A fixed creation date: both exports would otherwise take the clock's second.
|
||
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true, "created": 1_700_000_000});
|
||
let expected = super::super::export::pdf_export(&s, ¶ms).unwrap().bytes;
|
||
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!(!job.grayscale);
|
||
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());
|
||
assert!(print_with(&mut s, &json!({"created": "today"}), |_, _| panic!("an invalid date must not spool")).is_err());
|
||
}
|
||
|
||
/// File › Print spools the PDF that File › Export PDF writes with the same settings: soft
|
||
/// effects as images, CMYK print color, the same warnings.
|
||
#[test]
|
||
fn print_spools_what_export_pdf_writes() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
let id = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone();
|
||
s.execute("object.fill", &json!({"ids": [id], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap();
|
||
s.execute("object.dropShadow", &json!({"ids": [id], "on": true, "distance": 8, "size": 6})).unwrap();
|
||
let params = json!({"standard": "x4"});
|
||
// The creation date is in whole seconds: an attempt can straddle a second boundary.
|
||
let mut matched = false;
|
||
for _ in 0..3 {
|
||
let exported = s.execute("file.exportPdf", ¶ms).unwrap();
|
||
let mut printed = Vec::new();
|
||
let r = print_with(&mut s, ¶ms, |pdf, _| {
|
||
printed = pdf.to_vec();
|
||
Ok("request id is Office-1".into())
|
||
})
|
||
.unwrap();
|
||
assert_eq!(r["warnings"], exported["warnings"]);
|
||
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("soft effects")), "{r}");
|
||
if printed == super::super::file::base64_decode(exported["base64"].as_str().unwrap()) {
|
||
matched = true;
|
||
break;
|
||
}
|
||
}
|
||
assert!(matched, "File › Print spools Export PDF's bytes");
|
||
}
|
||
}
|