From c20f3aff606900dc3b7e444b972a728e9ac2b670 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 11 Oct 2026 07:29:54 +0200 Subject: [PATCH 1/2] M10.5: Adopt shared craft-print system spooler --- Cargo.lock | 9 + Cargo.toml | 4 +- README.md | 4 +- ROADMAP.md | 12 +- crates/print/Cargo.toml | 1 + crates/print/README.md | 21 +- crates/print/src/spool.rs | 545 +---------------------------- crates/print/src/tests.rs | 192 +--------- crates/print/src/windows_print.ps1 | 136 ------- docs/architecture.md | 4 +- docs/hardware-parity.md | 5 +- parity/acrobat-features.toml | 8 +- 12 files changed, 54 insertions(+), 887 deletions(-) delete mode 100644 crates/print/src/windows_print.ps1 diff --git a/Cargo.lock b/Cargo.lock index d42992b9c..9abe21229 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1090,6 +1090,14 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-print" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=c22bb1b0ce6316f84973cb69675fe7331c5886cc#c22bb1b0ce6316f84973cb69675fe7331c5886cc" +dependencies = [ + "tempfile", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -3840,6 +3848,7 @@ dependencies = [ name = "pdfcraft-print" version = "0.6.0" dependencies = [ + "craft-print", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", diff --git a/Cargo.toml b/Cargo.toml index 0214a7bf4..36e256011 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,12 +8,14 @@ default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] version = "0.6.0" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +# Shared system spooler; document composition remains in pdfcraft-print. +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c22bb1b0ce6316f84973cb69675fe7331c5886cc" } # internal pdfcraft-geom = { path = "crates/geom", version = "0.6.0" } pdfcraft-filters = { path = "crates/filters", version = "0.6.0" } diff --git a/README.md b/README.md index 555eae3bd..1b6cd0a05 100644 --- a/README.md +++ b/README.md @@ -416,10 +416,10 @@ PdfCraft is young and moving fast. The aim is a workbench where you can view, or - **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP. - **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built. -- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing on Windows, and full XFA forms. +- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, physical printer fidelity, and full XFA forms. - **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side. -**Next, in order:** encrypted files that Acrobat opens, the reported bugs and Windows printing, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish. +**Next, in order:** encrypted files that Acrobat opens, the reported bugs and physical printer validation, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish. The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md). diff --git a/ROADMAP.md b/ROADMAP.md index 94ca375d0..bc3afae70 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; native Windows validation pending) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI @@ -42,15 +42,15 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i of 0.4–0.5, switching reports such as #179). Two are only partial: editing (no CJK/RTL) and convert and share (password-protected files we write don't open in Acrobat or Reader, #774; no in-app Office→PDF; Word export loses layout, #773). Those, unmeasured quality against Acrobat, the borrowed -`hayro` renderer, no printing on Windows (#756) and 245 open issues keep it far from beta. +`hayro` renderer, unvalidated physical printer output (#756) and 245 open issues keep it far from beta. ### Readiness by audience | Audience | Ready | Opus 5.5 agent-hours to ≈ 95% | Work that dominates | |---|---|---|---| | Full Acrobat Pro (ready for real work) | ≈ 49% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions | -| Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, Windows printing, the bug backlog | -| Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, Windows printing, launch and GPU stability, encrypted output Acrobat opens (#774), simple text fixes, Word export layout, Chinese UI glyphs | +| Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, Windows print fidelity, the bug backlog | +| Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, Windows print fidelity, launch and GPU stability, encrypted output Acrobat opens (#774), simple text fixes, Word export layout, Chinese UI glyphs | Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#effort-and-calibration) (≈ 1k kept lines per agent-hour for new features, 3–10× slower for hardening and fidelity). The audiences are nested subsets, so essentials ≤ mainstream ≤ full. @@ -122,7 +122,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | Rank | Milestone | Estimate (h) | |---|---|---| | 0 | Convert and share: encrypted output opens in Acrobat (#774), Create PDF from Office, Word export that keeps layout (#773) | 40–70 | -| 1 | Backlog and platform: crash and wrong-result reports, Windows printing (#756), Chinese UI face (#826) | 65–120 | +| 1 | Backlog and platform: crash and wrong-result reports, physical printer validation (#756), Chinese UI face (#826) | 65–120 | | 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 | | 3 | Editing existing content (M7) | 100–180 | | 4 | OCR languages and Office export/import (M10) | 95–175 | @@ -135,6 +135,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-11 (M10.5):** Extracted printer discovery, options and PDF submission into craft-libs/craft-print, shared with DesignCraft. Windows preserves composed sheet size and orientation without fitting into default margins; duplex binding edges are corrected. PdfCraft retains permission checks and imposition. Shared transport and local composition, engine, automation and UI coverage validate the boundary; native Windows regression execution is pending; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change. + - **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %. - **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged. diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index 83b98de7a..3c9b1071d 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +craft-print = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-content = { workspace = true } pdfcraft-model = { workspace = true } diff --git a/crates/print/README.md b/crates/print/README.md index a9c371e05..b73c6ff5e 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -32,15 +32,18 @@ Each source page becomes a Form XObject (its content wrapped in q/Q, plus the pr annotations); sheets place them with a clip. The result is a fresh, unencrypted, garbage-collected file (callers check the print permission). -`spool` talks to CUPS on macOS and Linux: printers come from `lpstat -p -d` (the queues) and -`lpstat -e` (driverless destinations no queue exists for; CUPS builds a temporary one when the -job arrives), jobs go to `lp` with copies, collation, duplex and monochrome options. The job is -piped to `lp` on stdin and never written to a temp file, where another local user could read or -swap it. On Windows the printers come from `Win32_Printer` and jobs are rendered by the in-box -`Windows.Data.Pdf` and spooled by `System.Drawing.Printing` with the driver's own settings (both -through `powershell`, so no Win32 API is called here); that job file exists for the seconds the -spooler reads it, under a fresh unpredictable name. Other platforms report that printing to a -printer isn't available yet; the print-ready PDF can always be saved. +`spool` delegates discovery, driver options, preferences and submission to +[`craft-print`](https://github.com/storytold/craft-libs/tree/main/crates/print). +PdfCraft retains page selection, imposition, annotations and print-permission checks. +CUPS jobs stream through stdin with `fit-to-page=false`. Windows renders sheets through +`Windows.Data.Pdf` and spools them at their physical PDF size with per-page paper and +orientation, zero added margins and hardware-edge clipping. Long-edge and short-edge duplex +use the corresponding Windows binding edges. Random, exclusively created Windows job files +are removed on success and failure. Other targets offer the print-ready PDF instead. + +The shared dependency requires Rust 1.95. Transport and native Windows regression tests +live in craft-libs; local tests retain composition, facade, engine, automation and UI coverage. +Physical printer output still needs hardware validation. Not yet: the web spooler, print as image, poster labels, PostScript output, colour conversion for grayscale. diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index dcbe66c73..d6b797621 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -1,540 +1,15 @@ -//! 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. +//! System print spooler supplied by craft-print. PDF composition and permission checks +//! stay in PdfCraft; these adapters retain the application's print-error surface. -use crate::PrintError; +pub use craft_print::{ + Duplex, HANDLED_OPTIONS, HAS_PRINTER_PREFERENCES, Job, Printer, PrinterOption, lp_args, lpstat_command, parse_lpoptions, parse_lpstat, + parse_lpstat_e, parse_ppd, parse_windows_printers, ppd_text, printer_options, printers, printers_parsed, +}; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Printer { - pub name: String, - pub default: bool, +pub fn submit(pdf: &[u8], job: &Job) -> Result { + craft_print::submit(pdf, job).map_err(|e| crate::PrintError::Spool(e.to_string())) } -#[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, - 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 { - 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) }) - .collect() -} - -/// Parse `lpstat -e` output: destination names, one per line. -pub fn parse_lpstat_e(out: &str) -> Vec { - 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 { - 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 { - let mut out: Vec = Vec::new(); - let (mut group, mut group_key) = (String::new(), String::new()); - let mut open: Option = 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 { - #[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 { - #[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 { - 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 }); - } - } - } - 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. -#[cfg(windows)] -fn powershell_command(script: &str) -> std::process::Command { - let mut c = std::process::Command::new("powershell"); - c.args(["-NoProfile", "-NonInteractive", "-Command", script]); - c -} - -/// The Windows printer list: one line per printer, `*` on the system default. UTF-8, through -/// the output encoding the script sets first. Only the two 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 | ForEach-Object { if ($_.Default) { '*' + $_.Name } else { $_.Name } }"; - -/// The Print dialog's printers from the Windows printer list: one per line, `*` before the -/// system default (a Windows queue name may not contain `*`). 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 { - let mut printers: Vec = out - .lines() - .map(|l| l.strip_suffix('\r').unwrap_or(l)) - .filter(|l| !l.is_empty()) - .map(|l| match l.strip_prefix('*') { - Some(name) => Printer { name: name.to_string(), default: true }, - None => Printer { name: l.to_string(), default: false }, - }) - .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 { - #[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 { - 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 { - 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 { - 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() +pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> { + craft_print::open_printer_preferences(printer).map_err(|e| crate::PrintError::Spool(e.to_string())) } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 36caa4314..4e2c444e5 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use super::*; -use crate::spool::{Duplex, Job, Printer, lp_args, parse_lpstat, parse_windows_printers, windows_env_vars}; +use crate::spool::{Duplex, Job, Printer, lp_args, parse_lpstat, parse_windows_printers}; /// `n` pages of 200×300 (page 3 is landscape 300×200 when n ≥ 3); page i shows "(Page i+1)". /// Page 1 carries a printable square comment, a non-printing note, and a stamp. @@ -307,90 +307,6 @@ fn multiple_rejects_hostile_grids_without_panicking() { } } -/// Set on the child process when a test runs this test binary as a stand-in for `lp`. -const STAND_IN_LP: &str = "PDFCRAFT_STAND_IN_LP"; - -/// Not a test of its own: [`stand_in_lp`] re-runs this binary with only this test selected, and it -/// then plays `lp`. It records its arguments and stdin in the folder named by [`STAND_IN_LP`], or, -/// when the folder is `refuse`, exits without reading stdin, the way `lp` refuses an unknown -/// printer. -#[test] -fn stand_in_for_lp() { - let Some(record) = std::env::var_os(STAND_IN_LP) else { return }; - if record == "refuse" { - eprintln!("lp: The printer or class does not exist."); - std::process::exit(1); - } - let record = std::path::PathBuf::from(record); - let args: Vec = std::env::args().skip_while(|a| a != "--").skip(1).collect(); - let mut stdin = Vec::new(); - std::io::Read::read_to_end(&mut std::io::stdin(), &mut stdin).unwrap(); - std::fs::write(record.join("args"), args.join(" ")).unwrap(); - std::fs::write(record.join("stdin"), stdin).unwrap(); -} - -/// A spooler command that runs [`stand_in_for_lp`] with `record` (see there). -fn stand_in_lp(record: &std::ffi::OsStr) -> std::process::Command { - let mut c = std::process::Command::new(std::env::current_exe().unwrap()); - c.args(["--exact", "tests::stand_in_for_lp", "--nocapture", "--"]).env(STAND_IN_LP, record); - c -} - -#[test] -fn print_jobs_reach_lp_on_stdin_never_through_a_shared_temp_folder() { - // The job used to be written to `/pdfcraft-print-/job-