mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
Merge pull request #1015 from storytold/codex/craft-print
M10.5: Adopt craft-print spooler with Windows printing fixes
This commit is contained in:
16 files changed
+87
-981
No files matched your search
Generated
+14
-5
@@ -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]]
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+14
-11
@@ -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.
|
||||
|
||||
+10
-559
@@ -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<bool>,
|
||||
pub fn submit(pdf: &[u8], job: &Job) -> Result<String, crate::PrintError> {
|
||||
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<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()
|
||||
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()))
|
||||
}
|
||||
+3
-214
@@ -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<String> = 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 `<temp>/pdfcraft-print-<pid>/job-<time>.pdf` and handed to `lp`
|
||||
// by name. In a shared /tmp another local user can predict that folder, create it first (or
|
||||
// plant a symlink there) and so read every printed document or swap the file before `lp`
|
||||
// reads it. Piping the job to `lp` leaves nothing on disk.
|
||||
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let record =
|
||||
std::env::temp_dir().join(format!("pdfcraft-print-test-{}-{}", std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
|
||||
std::fs::create_dir(&record).unwrap();
|
||||
// Bigger than any pipe buffer, so the spooler must read while the job is still being written.
|
||||
let pdf: Vec<u8> = b"%PDF-1.7 synthetic print job\n".iter().copied().cycle().take(3 << 20).collect();
|
||||
let job = Job { title: "memo.pdf".into(), ..Job::default() };
|
||||
let sent = spool::submit_via(stand_in_lp(record.as_os_str()), &pdf, &job);
|
||||
let args = std::fs::read_to_string(record.join("args"));
|
||||
let stdin = std::fs::read(record.join("stdin"));
|
||||
let _ = std::fs::remove_dir_all(&record);
|
||||
assert!(sent.is_ok(), "{sent:?}");
|
||||
assert_eq!(
|
||||
args.unwrap(),
|
||||
"-n 1 -t memo.pdf -o collate=true -o sides=one-sided -o fit-to-page=false",
|
||||
"no file argument: lp reads the job from stdin"
|
||||
);
|
||||
assert!(stdin.unwrap() == pdf, "lp receives the whole job on stdin");
|
||||
let predictable = std::env::temp_dir().join(format!("pdfcraft-print-{}", std::process::id()));
|
||||
assert!(!predictable.exists(), "nothing is created at {predictable:?}, a name other local users can predict");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_print_job_reports_the_spoolers_message() {
|
||||
// `lp` refuses an unknown printer without reading the job; the user sees why, not a broken pipe.
|
||||
let pdf = vec![b'%'; 3 << 20];
|
||||
let err = spool::submit_via(stand_in_lp("refuse".as_ref()), &pdf, &Job::default());
|
||||
assert_eq!(err, Err(PrintError::Spool("lp: The printer or class does not exist.".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_spoolers_reply_drops_the_file_count_of_a_job_sent_on_stdin() {
|
||||
assert_eq!(
|
||||
spool::job_message(
|
||||
b"request id is Office_Laser-12 (0 file(s))
|
||||
"
|
||||
),
|
||||
"request id is Office_Laser-12"
|
||||
);
|
||||
assert_eq!(
|
||||
spool::job_message(
|
||||
b"request id is Office_Laser-13 (1 file(s))
|
||||
"
|
||||
),
|
||||
"request id is Office_Laser-13 (1 file(s))"
|
||||
);
|
||||
assert_eq!(spool::job_message(b""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_printers_carry_one_default_and_keep_their_names() {
|
||||
// Windows marks the system default with a `*` no queue name may contain; names hold spaces,
|
||||
@@ -462,134 +379,6 @@ fn windows_printers_carry_one_default_and_keep_their_names() {
|
||||
assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true) });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_windows_print_script_binds_long_edge_on_the_long_edge() {
|
||||
// System.Drawing's Duplex values are DEVMODE's: Vertical binds a portrait page on its long
|
||||
// edge, Horizontal on its short edge (checked against a PCL driver's @PJL SET BINDING).
|
||||
let script = include_str!("windows_print.ps1").replace("\r\n", "\n");
|
||||
assert!(script.contains("'long-edge' { [System.Drawing.Printing.Duplex]::Vertical }"));
|
||||
assert!(script.contains("'short-edge' { [System.Drawing.Printing.Duplex]::Horizontal }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_windows_job_is_the_environment_of_the_print_script() {
|
||||
let path = std::path::Path::new("C:\\Users\\me\\AppData\\Local\\Temp\\pdfcraft-print-42.pdf");
|
||||
let job = Job {
|
||||
printer: Some("HP \"Laser\" Jet".into()),
|
||||
copies: 3,
|
||||
duplex: Duplex::ShortEdge,
|
||||
grayscale: true,
|
||||
title: "Angebot.pdf".into(),
|
||||
..Job::default()
|
||||
};
|
||||
let vars = windows_env_vars(path, &job);
|
||||
let env = |key: &str| vars.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()).unwrap();
|
||||
assert_eq!(env("PDFCRAFT_PRINT_FILE"), "C:\\Users\\me\\AppData\\Local\\Temp\\pdfcraft-print-42.pdf");
|
||||
// Quotes and spaces in a printer name survive because nothing goes on the command line.
|
||||
assert_eq!(env("PDFCRAFT_PRINT_PRINTER"), "HP \"Laser\" Jet");
|
||||
assert_eq!(env("PDFCRAFT_PRINT_COPIES"), "3");
|
||||
assert_eq!(env("PDFCRAFT_PRINT_COLLATE"), "1");
|
||||
assert_eq!(env("PDFCRAFT_PRINT_DUPLEX"), "short-edge");
|
||||
assert_eq!(env("PDFCRAFT_PRINT_GRAYSCALE"), "1");
|
||||
assert_eq!(env("PDFCRAFT_PRINT_TITLE"), "Angebot.pdf");
|
||||
// No printer chosen means the system default: an empty name, not the word "default".
|
||||
let one = |j: &Job, key: &str| windows_env_vars(path, j).into_iter().find(|(k, _)| k == key).unwrap().1;
|
||||
assert_eq!(one(&Job::default(), "PDFCRAFT_PRINT_PRINTER"), "");
|
||||
assert_eq!(one(&Job { duplex: Duplex::Off, ..Job::default() }, "PDFCRAFT_PRINT_DUPLEX"), "off");
|
||||
assert_eq!(one(&Job { duplex: Duplex::LongEdge, ..Job::default() }, "PDFCRAFT_PRINT_DUPLEX"), "long-edge");
|
||||
}
|
||||
|
||||
/// Run a PowerShell script for the Windows-only tests: its stdout, or everything it said when
|
||||
/// it failed — the tests print that as the skip reason.
|
||||
#[cfg(windows)]
|
||||
fn windows_powershell(script: &str, envs: &[(&str, &str)]) -> Result<String, String> {
|
||||
let mut c = std::process::Command::new("powershell");
|
||||
c.args(["-NoProfile", "-NonInteractive", "-Command", script]);
|
||||
for (k, v) in envs {
|
||||
c.env(k, v);
|
||||
}
|
||||
match c.output() {
|
||||
Ok(o) if o.status.success() => Ok(String::from_utf8_lossy(&o.stdout).into_owned()),
|
||||
Ok(o) => Err(format!("{} {}", String::from_utf8_lossy(&o.stderr).trim(), String::from_utf8_lossy(&o.stdout).trim())),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: the printer list comes from the spooler. A bare machine may have no printer; the
|
||||
/// list that exists has real names and at most one default.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_lists_the_printers_the_system_knows() {
|
||||
let printers = spool::printers();
|
||||
assert!(printers.iter().all(|p| !p.name.trim().is_empty()), "{printers:?}");
|
||||
assert!(printers.iter().filter(|p| p.default).count() <= 1, "{printers:?}");
|
||||
}
|
||||
|
||||
/// Windows: PowerShell is started by its full path under System32, never by its bare name.
|
||||
/// Rust resolves a bare name in the app's own folder first, so a `powershell.exe` beside a
|
||||
/// portable install would run instead, for every printer list and every job.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_starts_powershell_by_its_full_path() {
|
||||
let c = crate::spool::powershell_command("$null");
|
||||
let program = std::path::Path::new(c.get_program());
|
||||
assert!(program.is_absolute(), "{program:?}");
|
||||
assert!(program.ends_with("System32/WindowsPowerShell/v1.0/powershell.exe"), "{program:?}");
|
||||
assert!(program.is_file(), "{program:?}");
|
||||
}
|
||||
|
||||
/// Windows: the embedded print script is valid PowerShell, checked on every machine — including
|
||||
/// runners that have no printer to print to.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn the_windows_print_script_parses() {
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-print-script-{}.ps1", std::process::id()));
|
||||
std::fs::write(&path, spool::WINDOWS_PRINT_SCRIPT).unwrap();
|
||||
let errors = windows_powershell(
|
||||
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile($env:PDFCRAFT_TEST_SCRIPT, [ref]$tokens, [ref]$errors) > $null; $errors.Count",
|
||||
&[("PDFCRAFT_TEST_SCRIPT", path.to_str().unwrap())],
|
||||
);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(errors.as_deref().map(str::trim), Ok("0"), "parse errors: {errors:?}");
|
||||
}
|
||||
|
||||
/// Windows, end to end: the script renders the job's sheets with Windows.Data.Pdf on any
|
||||
/// machine — CI included, no printer needed (the dry run writes the PNGs to the temporary
|
||||
/// folder) — and, where a paused test printer can be added, a real job lands in its queue.
|
||||
/// Its driver is the inbox "Generic / Text Only"; a machine that cannot add one (no rights, no
|
||||
/// driver) skips that half, as the CUPS tests skip where CUPS isn't running.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn the_print_script_renders_the_job_and_reaches_the_spooler() {
|
||||
let pdf = impose(&fixture(2), &settings(vec![0, 1], Layout::Size(SizeMode::Fit))).unwrap();
|
||||
// The printer name is deliberately wrong: the dry run stops before the driver is touched.
|
||||
let job = Job { printer: Some("no such printer".into()), title: "test.pdf".into(), ..Job::default() };
|
||||
let dry = spool::windows_submit(&pdf, &job, &[("PDFCRAFT_PRINT_DRYRUN", "1")]);
|
||||
assert_eq!(dry.as_deref(), Ok("rendered 2 sheet(s)"), "{dry:?}");
|
||||
|
||||
const NAME: &str = "PdfCraft Test Print";
|
||||
let added = windows_powershell(
|
||||
&format!(
|
||||
"if (-not (Get-Printer -Name '{NAME}' -ErrorAction SilentlyContinue)) {{ Add-Printer -Name '{NAME}' -DriverName 'Generic / Text Only' -PortName 'FILE:' }}; Set-Printer -Name '{NAME}' -IsPaused $true"
|
||||
),
|
||||
&[],
|
||||
);
|
||||
if let Err(why) = added {
|
||||
eprintln!("skipping the real print: the test printer could not be prepared: {why}");
|
||||
return;
|
||||
}
|
||||
let job = Job { printer: Some(NAME.into()), title: "test.pdf".into(), ..Job::default() };
|
||||
let sent = spool::submit(&pdf, &job);
|
||||
let queued = windows_powershell(
|
||||
"if (Get-PrintJob -Name 'PdfCraft Test Print') { (Get-PrintJob -Name 'PdfCraft Test Print' | Measure-Object).Count } else { 0 }",
|
||||
&[],
|
||||
);
|
||||
let cleaned = windows_powershell("Get-PrintJob -Name 'PdfCraft Test Print' | Remove-PrintJob; Remove-Printer -Name 'PdfCraft Test Print'", &[]);
|
||||
assert!(sent.is_ok(), "{sent:?}");
|
||||
assert_eq!(queued.as_deref().map(str::trim), Ok("1"), "the job is in the paused printer's queue: {queued:?}");
|
||||
assert!(cleaned.is_ok(), "the test printer is removed again: {cleaned:?}");
|
||||
}
|
||||
|
||||
/// A PPD shaped like a Fiery's: installable options, multi-line PostScript in the choices, Latin-1
|
||||
/// labels, options the Print dialog sets itself.
|
||||
const FIERY_PPD: &[u8] = b"*PPD-Adobe: \"4.3\"\n\
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
# Print a print-ready PDF on Windows (issue #756). The job arrives in PDFCRAFT_* environment
|
||||
# variables, never as arguments: printer names contain spaces and quotes, and the document's
|
||||
# path must not be visible in the process list while it prints.
|
||||
#
|
||||
# The in-box Windows.Data.Pdf renders each sheet at the printer's own resolution and
|
||||
# System.Drawing.Printing spools it with the driver's settings — copies, collation, duplex and
|
||||
# colour reach the spooler as the job's, the way `lp -o` sends them on CUPS. No PDF reader is
|
||||
# needed, and the calling code touches no Win32 API.
|
||||
#
|
||||
# PDFCRAFT_PRINT_DRYRUN=1 renders every sheet to a PNG in a temporary folder and prints
|
||||
# nothing: the tests run the whole rendering path on machines without a printer, CI included.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
|
||||
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
try {
|
||||
$null = [Windows.Data.Pdf.PdfDocument, Windows.Data.Pdf, ContentType = WindowsRuntime]
|
||||
} catch {
|
||||
throw 'this Windows installation cannot render PDFs: the Windows.Data.Pdf component is missing'
|
||||
}
|
||||
$null = [Windows.Storage.StorageFile, Windows.Storage, ContentType = WindowsRuntime]
|
||||
|
||||
# Await the WinRT asynchronous APIs from .NET Framework's PowerShell. AsTask is an extension
|
||||
# method: found by reflecting over the static class — with `.`, an instance call on the type
|
||||
# object, since `GetMethods` is not static and `::` would not find it.
|
||||
$type = [System.WindowsRuntimeSystemExtensions]
|
||||
$opTask = $type.GetMethods() | Where-Object {
|
||||
$_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1'
|
||||
} | Select-Object -First 1
|
||||
$actTask = $type.GetMethods() | Where-Object {
|
||||
$_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncAction'
|
||||
} | Select-Object -First 1
|
||||
function AwaitOp($operation, $resultType) {
|
||||
$task = $opTask.MakeGenericMethod($resultType).Invoke($null, @($operation))
|
||||
$task.Wait()
|
||||
$task.Result
|
||||
}
|
||||
function AwaitAct($action) {
|
||||
$task = $actTask.Invoke($null, @($action))
|
||||
$task.Wait()
|
||||
}
|
||||
|
||||
# One page rendered the size it will be drawn (`$drawW` × `$drawH`, hundredths of an inch), at
|
||||
# `$dpi` dots per inch, aspect-fitted. The caller disposes the bitmap.
|
||||
function Render-Sheet($page, $drawW, $drawH, $dpi) {
|
||||
$w = $page.Size.Width
|
||||
$h = $page.Size.Height
|
||||
$scale = [Math]::Min($drawW / 100.0 * 96.0 / $w, $drawH / 100.0 * 96.0 / $h)
|
||||
$options = [Windows.Data.Pdf.PdfPageRenderOptions]::new()
|
||||
$options.DestinationWidth = [int][Math]::Ceiling($w * $scale * $dpi / 96.0)
|
||||
$options.DestinationHeight = [int][Math]::Ceiling($h * $scale * $dpi / 96.0)
|
||||
$stream = [System.IO.MemoryStream]::new()
|
||||
try {
|
||||
$random = [System.IO.WindowsRuntimeStreamExtensions]::AsRandomAccessStream($stream)
|
||||
AwaitAct ($page.RenderToStreamAsync($random, $options))
|
||||
$stream.Position = 0
|
||||
# Copy out of the stream: GDI+ reads an Image lazily and needs its stream alive, so the
|
||||
# render below releases the stream with the function.
|
||||
$png = [System.Drawing.Image]::FromStream($stream)
|
||||
try { return [System.Drawing.Bitmap]::new($png) } finally { $png.Dispose() }
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
$source = AwaitOp ([Windows.Storage.StorageFile]::GetFileFromPathAsync($env:PDFCRAFT_PRINT_FILE)) ([Windows.Storage.StorageFile])
|
||||
$document = AwaitOp ([Windows.Data.Pdf.PdfDocument]::LoadFromFileAsync($source)) ([Windows.Data.Pdf.PdfDocument])
|
||||
if ($document.PageCount -lt 1) { throw 'the print-ready PDF has no pages' }
|
||||
|
||||
if ($env:PDFCRAFT_PRINT_DRYRUN -eq '1') {
|
||||
# Every sheet rendered as the printer would take it, to prove the path without one.
|
||||
$folder = Join-Path ([System.IO.Path]::GetTempPath()) ('pdfcraft-dryrun-' + $PID)
|
||||
$null = New-Item -ItemType Directory -Force $folder
|
||||
for ($i = 0; $i -lt $document.PageCount; $i++) {
|
||||
$page = $document.GetPage($i)
|
||||
try {
|
||||
$bitmap = Render-Sheet $page 750 1000 150
|
||||
try { $bitmap.Save((Join-Path $folder "sheet-$i.png"), [System.Drawing.Imaging.ImageFormat]::Png) } finally { $bitmap.Dispose() }
|
||||
} finally {
|
||||
$page.Dispose()
|
||||
}
|
||||
}
|
||||
Write-Output ('rendered ' + $document.PageCount + ' sheet(s)')
|
||||
return
|
||||
}
|
||||
|
||||
$printer = [System.Drawing.Printing.PrintDocument]::new()
|
||||
$printer.DocumentName = $env:PDFCRAFT_PRINT_TITLE
|
||||
$settings = $printer.PrinterSettings
|
||||
if ($env:PDFCRAFT_PRINT_PRINTER) {
|
||||
try {
|
||||
$settings.PrinterName = $env:PDFCRAFT_PRINT_PRINTER
|
||||
} catch {
|
||||
throw ('the printer is not available: ' + $env:PDFCRAFT_PRINT_PRINTER)
|
||||
}
|
||||
}
|
||||
$settings.Copies = [Math]::Min(32767, [Math]::Max(1, [int]$env:PDFCRAFT_PRINT_COPIES))
|
||||
$settings.Collate = ($env:PDFCRAFT_PRINT_COLLATE -eq '1')
|
||||
# DEVMODE's names are about the binding edge of a portrait page: Vertical (DMDUP_VERTICAL)
|
||||
# binds on the long edge, Horizontal (DMDUP_HORIZONTAL) on the short edge.
|
||||
$settings.Duplex = switch ($env:PDFCRAFT_PRINT_DUPLEX) {
|
||||
'long-edge' { [System.Drawing.Printing.Duplex]::Vertical }
|
||||
'short-edge' { [System.Drawing.Printing.Duplex]::Horizontal }
|
||||
default { [System.Drawing.Printing.Duplex]::Simplex }
|
||||
}
|
||||
if ($env:PDFCRAFT_PRINT_GRAYSCALE -eq '1') { $printer.DefaultPageSettings.Color = $false }
|
||||
|
||||
# The sheets are already laid out at their final size (CUPS gets `fit-to-page=false`), so each
|
||||
# one is printed at 100% on paper of its own size and orientation. Before every sheet the driver
|
||||
# is asked for that paper — the size closest to the sheet's, within an eighth of an inch, turned
|
||||
# either way — and turned landscape for a wide sheet; a driver without that paper keeps its own.
|
||||
$paperSizes = @($settings.PaperSizes)
|
||||
$script:sheets = 0
|
||||
$printer.add_QueryPageSettings({
|
||||
param($sender, $e)
|
||||
if ($script:sheets -ge $document.PageCount) { return }
|
||||
$page = $document.GetPage($script:sheets)
|
||||
try {
|
||||
# Windows.Data.Pdf measures in 1/96 inch, PageSettings in 1/100 inch.
|
||||
$w = $page.Size.Width / 96.0 * 100.0
|
||||
$h = $page.Size.Height / 96.0 * 100.0
|
||||
} finally {
|
||||
$page.Dispose()
|
||||
}
|
||||
$long = [Math]::Max($w, $h)
|
||||
$short = [Math]::Min($w, $h)
|
||||
$best = $null
|
||||
$bestOff = 12.5
|
||||
foreach ($paper in $paperSizes) {
|
||||
$off = [Math]::Abs([Math]::Max($paper.Width, $paper.Height) - $long) + [Math]::Abs([Math]::Min($paper.Width, $paper.Height) - $short)
|
||||
if ($off -lt $bestOff) { $best = $paper; $bestOff = $off }
|
||||
}
|
||||
if ($best) { $e.PageSettings.PaperSize = $best }
|
||||
$e.PageSettings.Landscape = ($w -gt $h)
|
||||
})
|
||||
|
||||
# One sheet at a time: its page is rendered when the driver asks for it, so a long document
|
||||
# holds only one page's bitmap in memory.
|
||||
$printer.add_PrintPage({
|
||||
param($sender, $e)
|
||||
if ($script:sheets -ge $document.PageCount) { $e.HasMorePages = $false; return }
|
||||
$page = $document.GetPage($script:sheets)
|
||||
$bitmap = $null
|
||||
try {
|
||||
# Hundredths of an inch throughout. The sheet is drawn at 100%, centred on the whole
|
||||
# page — not its 1-inch default margins — and shrunk only when the paper is smaller than
|
||||
# the sheet. The drawing origin is the printable area's corner, so the page's own corner
|
||||
# is the hard margin back from it; what falls outside the printable area is clipped, as
|
||||
# on CUPS.
|
||||
$paper = $e.PageBounds
|
||||
$w = $page.Size.Width / 96.0 * 100.0
|
||||
$h = $page.Size.Height / 96.0 * 100.0
|
||||
$scale = [Math]::Min(1.0, [Math]::Min($paper.Width / $w, $paper.Height / $h))
|
||||
$drawW = $w * $scale
|
||||
$drawH = $h * $scale
|
||||
# The printer's resolution, at most 600 dpi and at most ~64 megapixels a sheet, so a
|
||||
# poster-sized sheet can't run the machine out of memory.
|
||||
$dpi = [Math]::Min(600, [Math]::Max(72, $e.Graphics.DpiX))
|
||||
$dpi = [Math]::Max(36, [Math]::Min($dpi, [Math]::Sqrt(64e6 / ($drawW / 100.0 * $drawH / 100.0))))
|
||||
$bitmap = Render-Sheet $page $drawW $drawH $dpi
|
||||
$left = ($paper.Width - $drawW) / 2.0 - $e.PageSettings.HardMarginX
|
||||
$top = ($paper.Height - $drawH) / 2.0 - $e.PageSettings.HardMarginY
|
||||
$box = [System.Drawing.RectangleF]::new($left, $top, $drawW, $drawH)
|
||||
$e.Graphics.DrawImage($bitmap, $box)
|
||||
} finally {
|
||||
if ($bitmap) { $bitmap.Dispose() }
|
||||
$page.Dispose()
|
||||
}
|
||||
$script:sheets++
|
||||
$e.HasMorePages = ($script:sheets -lt $document.PageCount)
|
||||
})
|
||||
$printer.Print()
|
||||
Write-Output ('spooled ' + $document.PageCount + ' sheet(s)')
|
||||
@@ -164,7 +164,8 @@ impl PrintDraft {
|
||||
printer: self.printer.clone(),
|
||||
copies: self.copies.max(1),
|
||||
collate: self.collate,
|
||||
duplex: self.duplex,
|
||||
duplex: Some(self.duplex),
|
||||
scaling: spool::Scaling::Exact,
|
||||
grayscale: self.grayscale,
|
||||
title: title.to_string(),
|
||||
// Only for the printer the options were read from.
|
||||
@@ -903,6 +904,15 @@ pub(crate) fn body(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn composed_sheets_keep_exact_scaling_and_the_dialogs_duplex_choice() {
|
||||
let draft = PrintDraft { duplex: spool::Duplex::LongEdge, ..PrintDraft::default() };
|
||||
let job = draft.job("report.pdf");
|
||||
assert_eq!(job.scaling, spool::Scaling::Exact);
|
||||
assert_eq!(job.duplex, Some(spool::Duplex::LongEdge));
|
||||
assert_eq!(PrintDraft::default().job("report.pdf").duplex, Some(spool::Duplex::Off));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_preview_rasters_stay_within_the_texture_limit() {
|
||||
// Actual size on a 14,400 pt (200 in) page would ask for a raster far past any texture.
|
||||
|
||||
@@ -94,6 +94,7 @@ fn shared_properties_preserve_mixed_values_and_undo_the_whole_change() {
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Line Thickness:").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().bulk_field_props.as_ref().unwrap().width.apply, "thickness click enables the property");
|
||||
h.state_mut().bulk_field_props.as_mut().unwrap().width.value = 4.0;
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(4);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PdfCraft architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first public architecture document, written from the code on main) · **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; Windows CI passes) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
|
||||
How PdfCraft is built today. The design rationale and the planned crates live in
|
||||
`plan/architecture.md` and `plan/adr/` (local-only); this file describes what is on `main`. Each
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware and platform parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Windows printers row and 1:1 touchpad scrolling, #756, #759) · **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 craft-print spooler with passing Windows CI; Windows printers row and 1:1 touchpad scrolling, #756, #759) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
|
||||
Each hardware feature Acrobat Pro uses, per platform, ours against Acrobat's. Acrobat runs on macOS
|
||||
and Windows (plus a limited web viewer/editor in Document Cloud); PdfCraft runs on macOS, Windows,
|
||||
@@ -17,7 +17,7 @@ printing are counted in their feature areas). **Platforms ≈ 70% ready, 15–30
|
||||
| Multi-threaded rendering | yes | yes (render pool, tiles, watchdog) | yes | yes | single thread | `view.render-priority` |
|
||||
| HiDPI / Retina | yes | yes | yes, per-monitor DPI v2 (#324, #725) | yes | yes | text blurry at 1080p/1440p (#730) |
|
||||
| Multiple monitors | yes, multiple windows | one window | one window; surfaces fit the GPU limit (#577) | one window | n/a | `view.multiple-windows` planned |
|
||||
| Printers | all, PostScript, printer properties | CUPS spooler | Windows spooler: lists printers, prints rasterised at the printer's resolution (#756, PR #847) | CUPS spooler | **none** | `crates/print/README.md` |
|
||||
| Printers | all, PostScript, printer properties | CUPS via craft-print | Windows PDF raster spooler via craft-print (#756, PR #847) | CUPS via craft-print | **none** | `crates/print/README.md` |
|
||||
| Scanners (TWAIN, WIA, ICA) | yes | none | none | none (SANE) | n/a | `ocr.scanner-acquire` planned |
|
||||
| Smart cards and USB tokens | yes (PKCS #11, CryptoTokenKit, CNG) | Keychain identities (software keys) | CNG store keys, with PIN prompts for cards (#742) | none (PKCS #11 planned) | none | `sign.pkcs11` planned |
|
||||
| Pen and touch | Touch mode, pen ink, Windows Ink | trackpad pinch zoom; ink from pointer, no pressure | ink from pointer, no pressure or touch mode | as macOS | pointer | touchpad scrolling 1:1 (#759, PR #868) |
|
||||
@@ -42,4 +42,5 @@ printing are counted in their feature areas). **Platforms ≈ 70% ready, 15–30
|
||||
|---|---|---|
|
||||
| 2026-10-10 | major | Created from the code, `vendor/README.md`, `.github/workflows/release.yml`, the print crate's README and user issues |
|
||||
| 2026-10-10 | minor | Windows printing shipped (#756): the Windows row's gap is closed; the web row keeps "printing" |
|
||||
| 2026-10-11 | minor | Shared craft-print spooler; Windows preserves PDF sheet size/orientation and uses corrected duplex binding edges. Physical output remains a hardware check. |
|
||||
| 2026-10-11 | minor | The Printers row's Windows cell still said none: now the Windows spooler (#756, PR #847). Touchpad scrolling 1:1 (#759, PR #868) |
|
||||
@@ -7890,8 +7890,8 @@ milestone = "M10"
|
||||
status = "shipped"
|
||||
commands = ["print.dialog"]
|
||||
tools = ["printers", "printer_options", "doc_print"]
|
||||
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::windows_lists_the_printers_the_system_knows", "crates/print/src/tests.rs::the_print_script_renders_the_job_and_reaches_the_spooler", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/print.rs::print_dialog_shows_the_printer_driver_options"]
|
||||
notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows the queues and the default come from Win32_Printer (PowerShell CIM) and jobs are rendered with Windows.Data.Pdf and spooled with the driver's own settings, each sheet at 100% on the driver's paper closest to its size (landscape for a wide sheet; shrunk only when no paper is large enough), rasterised at the printer's resolution up to 600 dpi and about 64 megapixels a sheet, not sent as vectors; the printer list and the job run in the background, without a console window; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job"
|
||||
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked"]
|
||||
notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows the queues and the default come from Win32_Printer (PowerShell CIM) and jobs are rendered with Windows.Data.Pdf and spooled through shared craft-print at each sheet's physical size and orientation, with bounded raster resolution up to 600 dpi (not vector passthrough); the printer list and submission run on workers without a console window. Native Windows regression execution is pending; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job"
|
||||
|
||||
[[feature]]
|
||||
id = "print.copies-collate"
|
||||
@@ -7902,7 +7902,7 @@ milestone = "M10"
|
||||
status = "shipped"
|
||||
commands = ["print.dialog"]
|
||||
tools = ["doc_print"]
|
||||
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::the_windows_job_is_the_environment_of_the_print_script", "crates/print/src/tests.rs::the_print_script_renders_the_job_and_reaches_the_spooler"]
|
||||
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"]
|
||||
notes = "lp -n, collate option; on Windows the driver's Copies and Collate settings, as the job's"
|
||||
|
||||
[[feature]]
|
||||
@@ -7962,7 +7962,7 @@ milestone = "M10"
|
||||
status = "shipped"
|
||||
commands = ["print.dialog"]
|
||||
tools = ["doc_print"]
|
||||
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::the_windows_job_is_the_environment_of_the_print_script", "crates/print/src/tests.rs::the_print_script_renders_the_job_and_reaches_the_spooler"]
|
||||
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"]
|
||||
notes = "long/short edge via the spooler: lp -o sides=…, and the Windows driver's Duplex setting"
|
||||
|
||||
[[feature]]
|
||||
@@ -8058,7 +8058,7 @@ milestone = "M10"
|
||||
status = "partial"
|
||||
commands = ["print.dialog"]
|
||||
tools = ["doc_print"]
|
||||
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::the_windows_job_is_the_environment_of_the_print_script"]
|
||||
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"]
|
||||
notes = "asks the printer for monochrome (lp -o, and the Windows page settings as the job's); the PDF itself stays in colour"
|
||||
|
||||
[[feature]]
|
||||
|
||||
Reference in new issue
Block a user