diff --git a/Cargo.lock b/Cargo.lock index daea6b54a..1f2d2224f 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=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" +dependencies = [ + "tempfile", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -1741,7 +1749,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3860,6 +3868,7 @@ dependencies = [ name = "pdfcraft-print" version = "0.6.0" dependencies = [ + "craft-print", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", @@ -4900,7 +4909,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5526,7 +5535,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5794,7 +5803,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6486,7 +6495,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ddea910f4..84650ac3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,8 @@ 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 = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } # 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 e6227849c..3947c29ce 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,7 @@ 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 from the web build, 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, printing from the web build, 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:** Office-to-PDF and a Word export that keeps layout, the reported bugs, web printing and the Chinese interface font, 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. diff --git a/ROADMAP.md b/ROADMAP.md index 4da2c748f..2fd38f608 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -142,6 +142,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-12 (M10.5, hosted CI):** Integrated current main, including its date-table formatting and static-storage fixes. The Arabic synthetic drawing regression now checks the original serialized-output golden with and without the optional font face: the face-dependent FontName and FontFamily are checked separately and normalized only in the fixture. The hosted FreeBSD fingerprint is reproduced by those two metadata fields alone. Drawing bytes and all other fields retain the original golden; no printing behavior or readiness estimate changes. +- **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 CI passes, including paused-queue submission and cleanup; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change. - **2026-10-11 (M10, #1028):** Recognize Text can read pages that already have text: "Also recognize pages that already have text" in the dialog (remembered with the other choices, and used by Recognize text in multiple files) and `skip_text_pages: false` on `ocr_recognize` and, newly, `ocr_recognize_files`. A picture on such a page becomes searchable, and a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out, so nothing is found twice; `ocr_recognize` reports how many were left out as `already_on_page`. Before, the option existed only for agents and added the page's text a second time. The default still skips pages with text. Synthetic tests (a picture of a sentence plus real text) cover the engine, the tool and the dialog, and fail with the filter turned off. Still missing: reading only the picture areas (the whole page is read, as for a scan). Separately, `recognize_text_makes_a_scanned_page_searchable` fails on main wherever the models are installed (the model reads "quick" as "auick"). Estimates unchanged. - **2026-10-11 (M2, page-label prefixes):** The page-label readers bound the total size of decoded prefixes. Each range decodes its own prefix, and ranges may share one prefix object, so a small file could ask for a copy per range: within the 200,000-entry tree limit, about 200 MiB of copies of one 1,024-byte prefix. Inspection and Organize now refuse a tree whose decoded prefixes pass 4 MiB in total (`MAX_PREFIX_TOTAL_BYTES`), before any label is made, as the other tree limits do; relabelling such a tree changes nothing. Synthetic tests share one 1,000-byte prefix across 5,000 ranges and keep exactly 4 MiB of prefixes readable. Overall estimate unchanged. - **2026-10-11 (UI, checkboxes and radio buttons):** Checkboxes no longer look like radio buttons, and on the light theme neither is invisible. With the theme's 6 px widget radius egui drew a checkbox's 14 px box as a circle, so View ▸ Page display's Continuous scrolling and Show cover page read as two more layout choices; on the light theme an unticked box or an unselected radio button was white on white with no edge. Every checkbox (81) and radio button (79) now goes through `widgets::checkbox` / `widgets::radio` / `widgets::radio_value`: a 1 px outline (darker on hover), the plain fill on the light theme as dialogs already had, and for checkboxes a rounded-square box (3 px), keeping what the caller set (dialogs, the search panel). `clippy.toml` disallows `ui.checkbox`, `ui.radio`, `ui.radio_value`, `egui::Checkbox` and `egui::RadioButton` so new ones use them too. Estimates unchanged. diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 15b56eae8..b9529dcfa 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -141,12 +141,13 @@ impl Automation { printer: if printer == "default" { None } else { Some(printer.to_string()) }, copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32, collate: a.opt_bool("collate")?.unwrap_or(true), - duplex: match a.opt_str("duplex")?.unwrap_or("off") { + duplex: Some(match a.opt_str("duplex")?.unwrap_or("off") { "off" => spool::Duplex::Off, "long-edge" => spool::Duplex::LongEdge, "short-edge" => spool::Duplex::ShortEdge, d => return Err(bad(format!("unknown duplex {d:?}"))), - }, + }), + scaling: spool::Scaling::Exact, grayscale: a.opt_bool("grayscale")?.unwrap_or(false), title: name, options: match a.get("options") { diff --git a/crates/edit/src/added/arabic_tests.rs b/crates/edit/src/added/arabic_tests.rs index d2e818e1e..bc501e2d3 100644 --- a/crates/edit/src/added/arabic_tests.rs +++ b/crates/edit/src/added/arabic_tests.rs @@ -131,15 +131,25 @@ fn an_item_below_the_old_limit_is_drawn_exactly_as_before() { let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, id| Ok(boxed(0.5 + f64::from(id) * 0.01))); assert!(drawn.is_ok(), "{drawn:?}"); assert_eq!(fonts.len(), 1); + // The injected outlines are fixed; only the descriptor's family comes from craft-fonts. + // Check that metadata separately, then normalize those two fields to the original golden. + // Every drawing byte and every other object field must still match, in both build modes. + let family = pdfcraft_fonts::document_arabic_font().map_or("Arabic", |face| face.family); + for (_, font) in fonts.iter() { + let descriptor = doc.dict(font).unwrap().get(b"FontDescriptor").and_then(Object::as_ref).unwrap(); + let dict = doc.dict(&Object::Ref(descriptor)).unwrap(); + assert_eq!(dict.name(b"FontName"), Some(family.replace(' ', "").as_bytes())); + assert_eq!(dict.get(b"FontFamily").and_then(Object::as_string).unwrap().bytes, family.as_bytes()); + doc.update_dict(descriptor, |d| { + d.set(b"FontName".to_vec(), Object::name("Arabic")); + d.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(b"Arabic".to_vec()))); + }) + .unwrap(); + } let mut bytes = out.clone(); pdfcraft_cos::serialize(&Object::Dict(fonts.clone()), &mut bytes); bytes.extend(snapshot(&doc)); - // The golden was recorded without craft-fonts: with an Arabic face built in, the font's - // FontName and FontFamily carry that face's name instead of "Arabic", so only the build - // without it (CI's Windows job) can compare bytes. - if pdfcraft_fonts::document_arabic_font().is_none() { - assert_eq!(fnv1a(&bytes), SMALL_ITEM_FINGERPRINT, "output changed; the golden is the code before the sharding change"); - } + assert_eq!(fnv1a(&bytes), SMALL_ITEM_FINGERPRINT, "output changed; the golden is the code before the sharding change"); } #[test] 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 20b16f4f2..a7514023b 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -32,17 +32,20 @@ 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. Each sheet prints at 100% on the driver's -paper closest to its size, turned landscape when it is wide, and is rasterised at the printer's -resolution (up to 600 dpi), not sent as vectors. 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. +The desktop UI discovers printers and submits jobs on workers, so neither operation blocks +the dialog; the Windows backend starts without a console window. 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 0d949aae6..bad3ab210 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -1,564 +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, Scaling, 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, - /// 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, +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), two_sided: None }) - .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, 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 { - let mut printers: Vec = 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 { - #[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 bf186fb7d..dd4dbbbb3 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. @@ -244,10 +244,11 @@ fn spooler_arguments_and_printer_list() { printer: Some("Office_Laser".into()), copies: 3, collate: false, - duplex: Duplex::LongEdge, + duplex: Some(Duplex::LongEdge), grayscale: true, title: "memo.pdf".into(), options: Vec::new(), + ..Job::default() }; assert_eq!( lp_args(&job).join(" "), @@ -360,90 +361,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-