Files
pdfcraft/crates/print/src/spool.rs
T
fb97ce958d M10: Windows two-sided binds on the chosen edge; note drivers that ignore Two-sided (#1154)
The print script mapped "Flip on long edge" to System.Drawing's Duplex.Horizontal and
"short edge" to Vertical. Those are DEVMODE's DMDUP_HORIZONTAL (short-edge binding) and
DMDUP_VERTICAL (long-edge binding), so every Windows two-sided job bound on the other edge:
a PCL driver received @PJL SET BINDING = TOP for long edge. They are swapped back.

Win32_Printer's Capabilities say whether a driver prints two-sided (3, Duplex Printing). The
printer list already comes from Win32_Printer, so it now carries that as Printer::two_sided
(None on CUPS), the printers tool reports it, and the Print dialog notes when the selected
printer's driver doesn't take two-sided settings and points to Properties…. Such drivers keep
their own setting even with Two-sided Off. No vendor-specific names are used.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:41:02 -04:00

565 lines
26 KiB
Rust

//! The system print spooler. On macOS and Linux this is CUPS: printers come from `lpstat` — its
//! queues, and the driverless destinations it can print to without one — and jobs are piped to
//! `lp` with the job options (copies, collation, duplex, colour). On Windows the printers come
//! from `Win32_Printer` and jobs are rendered with the in-box `Windows.Data.Pdf` and spooled
//! with the driver's own settings — both through `powershell`, so no Win32 API is called here.
//! Other platforms report that printing isn't available yet; the print-ready PDF can still be
//! saved.
use crate::PrintError;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Printer {
pub name: String,
pub default: bool,
/// Whether the driver says it can print two-sided, where the spooler tells (Windows: the
/// printer's `Duplex Printing` capability). A driver without it ignores Two-sided and uses
/// its own setting. `None` when unknown (CUPS maps `sides` itself).
pub two_sided: Option<bool>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Duplex {
#[default]
Off,
LongEdge,
ShortEdge,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Job {
/// `None` = the system default printer.
pub printer: Option<String>,
pub copies: u32,
pub collate: bool,
pub duplex: Duplex,
pub grayscale: bool,
pub title: String,
/// The printer driver's own job options (a PPD keyword and the chosen value), such as the
/// paper tray or paper type; see [`printer_options`]. CUPS only.
pub options: Vec<(String, String)>,
}
impl Default for Job {
fn default() -> Self {
Job { printer: None, copies: 1, collate: true, duplex: Duplex::Off, grayscale: false, title: "PdfCraft".into(), options: Vec::new() }
}
}
/// The `lpstat -d` line's destination name, when the spooler has a default.
fn default_destination(out: &str) -> Option<&str> {
out.lines().find_map(|l| l.strip_prefix("system default destination:")).map(str::trim)
}
/// Parse `lpstat -p -d` output.
pub fn parse_lpstat(out: &str) -> Vec<Printer> {
let default = default_destination(out);
out.lines()
.filter_map(|l| l.strip_prefix("printer "))
.filter_map(|l| l.split_whitespace().next())
.map(|n| Printer { name: n.to_string(), default: default == Some(n), two_sided: None })
.collect()
}
/// Parse `lpstat -e` output: destination names, one per line.
pub fn parse_lpstat_e(out: &str) -> Vec<String> {
out.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect()
}
/// The `lp` arguments for a job. There is no file argument: `lp` reads the job from stdin.
pub fn lp_args(job: &Job) -> Vec<String> {
let mut a = Vec::new();
if let Some(p) = &job.printer {
a.extend(["-d".to_string(), p.clone()]);
}
a.extend(["-n".to_string(), job.copies.clamp(1, 999).to_string()]);
a.extend(["-t".to_string(), job.title.clone()]);
let mut opt = |o: &str| a.extend(["-o".to_string(), o.to_string()]);
opt(if job.collate { "collate=true" } else { "collate=false" });
opt(match job.duplex {
Duplex::Off => "sides=one-sided",
Duplex::LongEdge => "sides=two-sided-long-edge",
Duplex::ShortEdge => "sides=two-sided-short-edge",
});
if job.grayscale {
opt("print-color-mode=monochrome");
}
// The sheets are already laid out at their final size.
opt("fit-to-page=false");
for (key, value) in &job.options {
// Driver options are PPD keywords; anything else is not something lp can be given.
if is_ppd_keyword(key) && is_ppd_keyword(value) && !HANDLED_OPTIONS.contains(&key.as_str()) {
a.extend(["-o".to_string(), format!("{key}={value}")]);
}
}
a
}
/// One of the printer driver's job options (a PPD `OpenUI` entry), such as the paper tray.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PrinterOption {
/// The PPD keyword (`InputSlot`).
pub key: String,
/// What the driver calls it (`Paper tray`); the keyword when it gives no name.
pub label: String,
/// The driver's group (`Paper Handling`); empty outside a group.
pub group: String,
/// The choices: keyword and label (`Tray2`, `Tray 2`).
pub choices: Vec<(String, String)>,
/// The choice the printer uses when a job doesn't say.
pub default: String,
}
/// Options PdfCraft's Print dialog sets itself (paper, two-sided, collation), so the driver's
/// copies of them are neither shown nor sent.
pub const HANDLED_OPTIONS: &[&str] = &["PageSize", "PageRegion", "Duplex", "Collate", "ImageableArea", "PaperDimension"];
/// Caps on what a PPD may make the dialog hold (PPDs are text files from the printer vendor).
const MAX_OPTIONS: usize = 400;
const MAX_CHOICES: usize = 400;
/// A PPD keyword: printable ASCII without spaces, `/`, `:` or `=`.
fn is_ppd_keyword(s: &str) -> bool {
!s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_graphic() && !matches!(b, b'/' | b':' | b'=' | b'"'))
}
/// `Name/Label` → (`Name`, `Label`), the label falling back to the name.
fn name_label(s: &str) -> (&str, &str) {
match s.split_once('/') {
Some((n, l)) if !l.trim().is_empty() => (n.trim(), l.trim()),
Some((n, _)) => (n.trim(), n.trim()),
None => (s.trim(), s.trim()),
}
}
/// The job options a PPD offers, in file order, without the installable-hardware group and the
/// options PdfCraft sets itself ([`HANDLED_OPTIONS`]). Lenient: malformed lines are skipped.
pub fn parse_ppd(ppd: &str) -> Vec<PrinterOption> {
let mut out: Vec<PrinterOption> = Vec::new();
let (mut group, mut group_key) = (String::new(), String::new());
let mut open: Option<PrinterOption> = None;
for line in ppd.lines() {
let line = line.trim_end_matches('\r');
if let Some(rest) = line.strip_prefix("*OpenGroup:") {
let (k, l) = name_label(rest);
(group_key, group) = (k.to_string(), l.to_string());
} else if line.starts_with("*CloseGroup") {
(group_key, group) = (String::new(), String::new());
} else if let Some(rest) = line.strip_prefix("*OpenUI") {
let Some((head, _kind)) = rest.trim().rsplit_once(':') else { continue };
let (key, label) = name_label(head.trim().trim_start_matches('*'));
open = is_ppd_keyword(key).then(|| PrinterOption {
key: key.to_string(),
label: label.to_string(),
group: group.clone(),
choices: Vec::new(),
default: String::new(),
});
} else if line.starts_with("*CloseUI") {
if let Some(o) = open.take()
&& !o.choices.is_empty()
&& group_key != "InstallableOptions"
&& !HANDLED_OPTIONS.contains(&o.key.as_str())
&& out.len() < MAX_OPTIONS
{
out.push(o);
}
} else if let Some(o) = open.as_mut() {
let Some(rest) = line.strip_prefix('*') else { continue };
if let Some(value) = rest.strip_prefix("Default").and_then(|r| r.strip_prefix(o.key.as_str())).and_then(|r| r.strip_prefix(':')) {
o.default = value.trim().to_string();
} else if let Some(rest) = rest.strip_prefix(o.key.as_str()).and_then(|r| r.strip_prefix(' ')) {
// `*InputSlot Tray2/Tray 2: "<< ... >>"`; the code after the colon is the driver's.
let head = rest.split_once(':').map_or(rest, |(h, _)| h);
let (choice, label) = name_label(head);
if is_ppd_keyword(choice) && o.choices.len() < MAX_CHOICES && o.choices.iter().all(|(c, _)| c != choice) {
o.choices.push((choice.to_string(), label.to_string()));
}
}
}
}
for o in &mut out {
if !o.choices.iter().any(|(c, _)| *c == o.default) {
o.default = o.choices.first().map(|(c, _)| c.clone()).unwrap_or_default();
}
}
out
}
/// The current choice of each option in `lpoptions -p NAME -l` output (`Key/Label: a *b c`): the
/// queue's defaults, including the user's own (`~/.cups/lpoptions`).
pub fn parse_lpoptions(out: &str) -> Vec<(String, String)> {
out.lines()
.filter_map(|l| {
let (head, choices) = l.split_once(':')?;
let (key, _) = name_label(head);
let current = choices.split_whitespace().find_map(|c| c.strip_prefix('*'))?;
Some((key.to_string(), current.to_string()))
})
.collect()
}
/// A PPD's text: UTF-8, or ISO Latin-1 (the PPD default) when it isn't valid UTF-8.
pub fn ppd_text(bytes: &[u8]) -> String {
match std::str::from_utf8(bytes) {
Ok(s) => s.to_string(),
Err(_) => bytes.iter().map(|&b| char::from(b)).collect(),
}
}
/// The driver's job options for a printer (CUPS: its PPD, with the queue's current defaults);
/// empty when it has none or on other platforms. Reads files and runs `lpoptions`, so call it
/// when the user asks, not every frame.
pub fn printer_options(printer: &str) -> Vec<PrinterOption> {
#[cfg(all(unix, not(target_arch = "wasm32")))]
{
// Queue names never contain `/`; anything else is not a queue and not a path to follow.
if !is_ppd_keyword(printer) {
return Vec::new();
}
const PPD_CAP: u64 = 16 << 20;
let ppd = std::fs::File::open(format!("/etc/cups/ppd/{printer}.ppd")).ok().and_then(|f| {
use std::io::Read as _;
let mut bytes = Vec::new();
f.take(PPD_CAP).read_to_end(&mut bytes).ok().map(|_| bytes)
});
let mut options = ppd.map(|b| parse_ppd(&ppd_text(&b))).unwrap_or_default();
let current = std::process::Command::new("lpoptions")
.args(["-p", printer, "-l"])
.output()
.map(|o| parse_lpoptions(&String::from_utf8_lossy(&o.stdout)))
.unwrap_or_default();
for (key, value) in current {
if let Some(o) = options.iter_mut().find(|o| o.key == key)
&& o.choices.iter().any(|(c, _)| *c == value)
{
o.default = value;
}
}
options
}
#[cfg(not(all(unix, not(target_arch = "wasm32"))))]
{
let _ = printer;
Vec::new()
}
}
/// Whether [`open_printer_preferences`] can show the driver's own settings window (Windows).
pub const HAS_PRINTER_PREFERENCES: bool = cfg!(windows);
/// Open the printer driver's Printing Preferences window (Windows: `printui.dll`). What the user
/// sets there is saved as that printer's defaults for this Windows user, which every job starts
/// from; the Print dialog's copies, two-sided, grayscale and paper still apply on top.
pub fn open_printer_preferences(printer: &str) -> Result<(), PrintError> {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// printui takes the name in quotes; Windows printer names can't contain one anyway.
if printer.is_empty() || printer.contains('"') {
return Err(PrintError::Spool("not a printer name".into()));
}
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into());
let rundll = std::path::Path::new(&root).join("System32").join("rundll32.exe");
std::process::Command::new(rundll)
.raw_arg("printui.dll,PrintUIEntry")
.raw_arg("/e")
.raw_arg("/n")
.raw_arg(format!("\"{printer}\""))
.spawn()
.map(|_| ())
.map_err(|e| PrintError::Spool(format!("the printer's preferences could not be opened: {e}")))
}
#[cfg(not(windows))]
{
let _ = printer;
Err(PrintError::Spool("printer preferences open only on Windows; use the printer's options instead".into()))
}
}
/// `lpstat`, forced to print untranslated messages so [`parse_lpstat`] and [`parse_lpstat_e`] can
/// read them.
///
/// `LC_ALL`/`LANG=C` is enough on Linux. macOS CUPS ignores them and follows the user's
/// interface language (`AppleLanguages`) unless `SOFTWARE` is set, in which case it uses `LANG`.
fn lpstat_with(args: &[&str]) -> std::process::Command {
let mut c = std::process::Command::new("lpstat");
c.args(args).env("LC_ALL", "C").env("LANG", "C").env("SOFTWARE", "PdfCraft");
c
}
/// `lpstat -p -d`: the spooler's queues and the default destination.
pub fn lpstat_command() -> std::process::Command {
lpstat_with(&["-p", "-d"])
}
/// `lpstat -e`: every destination CUPS can print to, including driverless network printers no
/// permanent queue exists for (a queue is built only when a job needs one). GTK's and macOS's
/// print dialogs list them the same way. CUPS ≥ 1.7 (2013).
///
/// Gated like its only caller, `printers`' unix block: off unix this is dead code, and the
/// Windows clippy gate builds with `-D warnings`.
#[cfg(all(unix, not(target_arch = "wasm32")))]
fn lpstat_e_command() -> std::process::Command {
lpstat_with(&["-e"])
}
/// The printers the system knows (empty when there are none or no spooler): its queues, then the
/// driverless destinations CUPS can print to without one.
pub fn printers() -> Vec<Printer> {
#[cfg(all(unix, not(target_arch = "wasm32")))]
{
let Some(queues) = lpstat_command().output().ok().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) else {
return Vec::new();
};
// A spooler without `-e`, or one whose discovery times out, only loses the driverless names.
let available = lpstat_e_command().output().ok().map(|o| String::from_utf8_lossy(&o.stdout).into_owned());
printers_parsed(&queues, available.as_deref())
}
#[cfg(windows)]
{
// Win32_Printer through CIM: `wmic` was removed from Windows 11 (24H2) and
// `Get-CimInstance` is on every desktop install. A machine whose PowerShell is locked
// down lists no printers and the dialog offers only Save as PDF, as with CUPS that
// isn't running.
match powershell_command(POWERSHELL_PRINTERS).output() {
Ok(o) if o.status.success() => parse_windows_printers(&String::from_utf8_lossy(&o.stdout)),
_ => Vec::new(),
}
}
#[cfg(not(any(windows, all(unix, not(target_arch = "wasm32")))))]
{
Vec::new()
}
}
/// The Print dialog's printers from lpstat's two answers: the spooler's queues (`-p -d`), then
/// the driverless destinations of `available` (`-e`) that have no queue — CUPS builds a temporary
/// queue when `lp` sends them a job. Each destination once, queues first; the default still comes
/// from `-d`, so a driverless default keeps its marker. `None` when the spooler doesn't know `-e`
/// (CUPS < 1.7, 2013), which loses only the driverless names.
pub fn printers_parsed(queues: &str, available: Option<&str>) -> Vec<Printer> {
let mut printers = parse_lpstat(queues);
let default = default_destination(queues);
if let Some(names) = available {
for name in parse_lpstat_e(names) {
if !printers.iter().any(|p| p.name == name) {
printers.push(Printer { default: default == Some(name.as_str()), name, two_sided: None });
}
}
}
printers
}
/// The `powershell` command that runs a script. `-NoProfile` keeps a user's profile from
/// writing banners into what is parsed, `-NonInteractive` from waiting for input.
/// `CREATE_NO_WINDOW`: the release app has no console of its own, so without it Windows opens
/// a console window for PowerShell over the app for as long as the script runs.
///
/// Named by its full path, as `rundll32` is in [`open_printer_preferences`]: given a bare name,
/// Rust looks in the app's own folder before System32, so a `powershell.exe` beside a portable
/// `pdfcraft.exe` would run in its place each time the Print dialog opens and on every job.
#[cfg(windows)]
pub(crate) fn powershell_command(script: &str) -> std::process::Command {
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into());
let exe = std::path::Path::new(&root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe");
let mut c = std::process::Command::new(exe);
c.args(["-NoProfile", "-NonInteractive", "-Command", script]);
c.creation_flags(CREATE_NO_WINDOW);
c
}
/// The Windows printer list: one line per printer, `*` on the system default, then a tab and
/// `2` when the driver reports the Duplex Printing capability (3), `1` when it doesn't. UTF-8,
/// through the output encoding the script sets first. Only the properties the list needs are
/// pulled, so a machine with many printers doesn't wait for the rest of each record.
#[cfg(windows)]
const POWERSHELL_PRINTERS: &str = "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); Get-CimInstance Win32_Printer -Property Name, Default, Capabilities | ForEach-Object { $sides = if (@($_.Capabilities) -contains 3) { '2' } else { '1' }; if ($_.Default) { '*' + $_.Name + [char]9 + $sides } else { $_.Name + [char]9 + $sides } }";
/// The Print dialog's printers from the Windows printer list: one per line, `*` before the
/// system default (a Windows queue name may not contain `*`), and after a tab `2` or `1` for
/// whether the driver can print two-sided. The first `*` wins when a broken spooler marks more
/// than one, so the dialog still has a single default to preselect.
pub fn parse_windows_printers(out: &str) -> Vec<Printer> {
let mut printers: Vec<Printer> = out
.lines()
.map(|l| l.strip_suffix('\r').unwrap_or(l))
.filter(|l| !l.is_empty())
.map(|l| {
let (l, two_sided) = match l.rsplit_once('\t') {
Some((name, "2")) => (name, Some(true)),
Some((name, "1")) => (name, Some(false)),
_ => (l, None),
};
match l.strip_prefix('*') {
Some(name) => Printer { name: name.to_string(), default: true, two_sided },
None => Printer { name: l.to_string(), default: false, two_sided },
}
})
.collect();
let mut seen_default = false;
for p in &mut printers {
if p.default {
p.default = !seen_default;
seen_default = true;
}
}
printers
}
/// Send a print-ready PDF to the spooler. Returns the spooler's message (the job id).
pub fn submit(pdf: &[u8], job: &Job) -> Result<String, PrintError> {
#[cfg(all(unix, not(target_arch = "wasm32")))]
{
submit_via(std::process::Command::new("lp"), pdf, job)
}
#[cfg(windows)]
{
windows_submit(pdf, job, &[])
}
#[cfg(not(any(windows, all(unix, not(target_arch = "wasm32")))))]
{
let _ = (pdf, job);
Err(PrintError::Spool("printing to a printer isn't available on this platform yet; save the print-ready PDF instead".into()))
}
}
/// [`submit`] on Windows: the print-ready PDF goes to a job file of its own (WinRT reads from
/// a path), the embedded [`WINDOWS_PRINT_SCRIPT`] spools it, and the file is gone again however
/// the job ended. `extra_envs` reaches the script too — the tests use it for
/// `PDFCRAFT_PRINT_DRYRUN`, which renders without a printer.
#[cfg(windows)]
pub(crate) fn windows_submit(pdf: &[u8], job: &Job, extra_envs: &[(&str, &str)]) -> Result<String, PrintError> {
let path = windows_job_file(pdf)?;
let mut ps = powershell_command(WINDOWS_PRINT_SCRIPT);
for (key, value) in windows_env_vars(&path, job).into_iter().chain(extra_envs.iter().map(|(k, v)| (k.to_string(), v.to_string()))) {
ps.env(key, value);
}
let out = ps.output();
// Remove the job file before reporting: a spooler that refused the job must not leave the
// document behind.
let _ = std::fs::remove_file(&path);
let out = out.map_err(|e| PrintError::Spool(format!("the print spooler is not available: {e}")))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
let err = if err.is_empty() { String::from_utf8_lossy(&out.stdout).trim().to_string() } else { err };
return Err(PrintError::Spool(if err.is_empty() { "the print job was refused".into() } else { err }));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
/// The job file [`windows_submit`] hands to `powershell`, in this user's own temporary folder:
/// a fresh name no other local user can predict (`create_new`, with the process id and the
/// time), written before anything is spawned and removed after. WinRT reads a path, so unlike
/// the job piped to `lp` this one touches disk for the seconds the spooler reads it.
#[cfg(windows)]
fn windows_job_file(pdf: &[u8]) -> Result<std::path::PathBuf, PrintError> {
use std::io::Write as _;
let written = |e: std::io::Error| PrintError::Spool(format!("the print job could not be written: {e}"));
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
for attempt in 0..64u32 {
let path = std::env::temp_dir().join(format!("pdfcraft-print-{}-{nanos}-{attempt}.pdf", std::process::id()));
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(mut file) => {
file.write_all(pdf).map_err(written)?;
return Ok(path);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(written(e)),
}
}
Err(PrintError::Spool("the print job could not be written: no free name in the temporary folder".into()))
}
/// The print script's environment: no arguments, so a printer name with quotes or spaces
/// survives the command line and the document's path is not visible in the process list while
/// it prints. The driver options in [`Job::options`] are CUPS' PPD keywords; on Windows the
/// driver's own per-printer preferences apply (Print ▸ Properties…), so they are not sent.
#[cfg_attr(not(windows), allow(dead_code))]
pub(crate) fn windows_env_vars(path: &std::path::Path, job: &Job) -> Vec<(String, String)> {
vec![
("PDFCRAFT_PRINT_FILE".into(), path.to_string_lossy().into_owned()),
("PDFCRAFT_PRINT_PRINTER".into(), job.printer.clone().unwrap_or_default()),
("PDFCRAFT_PRINT_COPIES".into(), job.copies.to_string()),
("PDFCRAFT_PRINT_COLLATE".into(), if job.collate { "1" } else { "0" }.into()),
(
"PDFCRAFT_PRINT_DUPLEX".into(),
match job.duplex {
Duplex::Off => "off",
Duplex::LongEdge => "long-edge",
Duplex::ShortEdge => "short-edge",
}
.into(),
),
("PDFCRAFT_PRINT_GRAYSCALE".into(), if job.grayscale { "1" } else { "0" }.into()),
("PDFCRAFT_PRINT_TITLE".into(), job.title.clone()),
]
}
/// The embedded print script (see [`windows_submit`]).
#[cfg(windows)]
pub(crate) const WINDOWS_PRINT_SCRIPT: &str = include_str!("windows_print.ps1");
/// [`submit`] with the spooler command given, so tests can stand in for `lp`.
///
/// The job goes to `lp` on stdin, never through a file: a predictable job folder in a shared
/// temp directory lets another local user read printed documents or swap the file before `lp`
/// reads it.
#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))]
pub(crate) fn submit_via(mut lp: std::process::Command, pdf: &[u8], job: &Job) -> Result<String, PrintError> {
use std::io::Write;
use std::process::Stdio;
let mut child = lp
.args(lp_args(job))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| PrintError::Spool(format!("the print spooler is not available: {e}")))?;
let stdin = child.stdin.take();
// Feed the job on its own thread while collecting the output, so neither side can fill a
// pipe and wait on the other. Dropping `stdin` at the end closes it: the end of the job.
let fed_and_out = std::thread::scope(|s| {
let feeder = std::thread::Builder::new().name("print job".into()).spawn_scoped(s, move || match stdin {
Some(mut stdin) => stdin.write_all(pdf),
None => Err(std::io::Error::other("no pipe to the spooler")),
});
match feeder {
Ok(feeder) => {
let out = child.wait_with_output();
Ok((feeder.join(), out))
}
Err(e) => {
// No thread to feed it: stop `lp` rather than leave it with an empty job.
let _ = child.kill();
let _ = child.wait();
Err(PrintError::Spool(format!("the print job could not be sent to the spooler: {e}")))
}
}
});
let (fed, out) = fed_and_out?;
let out = out.map_err(|e| PrintError::Spool(format!("the print spooler is not available: {e}")))?;
if !out.status.success() {
// `lp` may refuse before reading the job (an unknown printer); its message says why,
// and the broken pipe that leaves behind does not.
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(PrintError::Spool(if err.is_empty() { "the print job was refused".into() } else { err }));
}
match fed {
Ok(Ok(())) => Ok(job_message(&out.stdout)),
Ok(Err(e)) => Err(PrintError::Spool(format!("the print job could not be sent to the spooler: {e}"))),
Err(_) => Err(PrintError::Spool("the print job could not be sent to the spooler".into())),
}
}
/// `lp`'s reply ("request id is Office-12"), without the "(0 file(s))" CUPS adds when the job came
/// on stdin: it reads as if nothing was sent.
#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))]
pub(crate) fn job_message(stdout: &[u8]) -> String {
let s = String::from_utf8_lossy(stdout);
let s = s.trim();
s.strip_suffix("(0 file(s))").map_or(s, str::trim_end).to_string()
}