mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-07 11:03:34 +00:00
Merge pull request #42: qualify native Windows printing
Validate native Windows submission and share RGB printing
This commit is contained in:
9 files changed
+241
-11
No files matched your search
Generated
+9
@@ -533,6 +533,7 @@ dependencies = [
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.8.9",
|
||||
"roxmltree",
|
||||
"tempfile",
|
||||
@@ -574,6 +575,14 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-winprint-gdi"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
|
||||
@@ -545,8 +545,13 @@ fn stand_in_separations_respect_the_ink_limit() {
|
||||
let t = Transform::new(Builtin::Srgb.profile(), cmyk, intent, true).unwrap();
|
||||
let mut out = vec![0.0f32; N * N * N * 4];
|
||||
t.convert_f32(&grid, 3, &mut out, 4, false).unwrap();
|
||||
let (i, max) =
|
||||
out.chunks_exact(4).map(|c| c.iter().sum::<f32>()).enumerate().fold((0, 0.0f32), |m, (i, s)| if s > m.1 { (i, s) } else { m });
|
||||
let (i, max) = out
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| c.iter().sum::<f32>())
|
||||
.enumerate()
|
||||
.fold((0, 0.0f32), |m, (i, s)| if s > m.1 { (i, s) } else { m });
|
||||
eprintln!("{name}, {intent:?}: max total ink {:.2} % at sRGB {:?}", max * 100.0, &grid[i * 3..i * 3 + 3]);
|
||||
assert!(max <= LIMIT, "{name}, {intent:?}: total ink {max} at sRGB {:?}", &grid[i * 3..i * 3 + 3]);
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "System PDF print spooler for the Craft apps: CUPS and Windows"
|
||||
|
||||
[dependencies]
|
||||
# Safe API; Win32 calls are isolated in this maintained MIT OR Apache-2.0 dependency.
|
||||
craft-winprint-gdi = { git = "https://github.com/storytold/winprint.rs", rev = "417ac3c4d14d3da5485a99c7d5ce8f05664ec312" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
+29
-2
@@ -69,6 +69,23 @@ a driver paper-size mismatch. On Windows `command_preview` describes the in-proc
|
||||
(`xps-print`, then the printer and options as `key=value`) instead of a command line. The crate
|
||||
does not provide PostScript output, separations, trapping or colour-managed device rendering.
|
||||
|
||||
Windows shrink-to-fit refuses pages with annotations when their geometry needs fitting.
|
||||
Callers must flatten annotation appearances into page content first. Exact-size printing and
|
||||
rotation retain the annotations. This avoids losing printed comments, forms or other marks.
|
||||
The Windows renderer uses viewer visibility. Native submission therefore accepts only plain
|
||||
visible printable annotation flags (`F=4`); annotations without Print, with NoView, or with
|
||||
other visibility/rotation flags require caller flattening. This prevents printing comments
|
||||
that were marked nonprinting or omitting print-only marks. A visible Square appearance with
|
||||
`F=4` was rendered in the Windows qualification fixture; other appearance types still need
|
||||
their own fidelity checks.
|
||||
|
||||
Native Windows PDF submission checks the driver's validated copy count and explicit
|
||||
simplex/duplex and grayscale requests before sending. Collation must be retained when both
|
||||
the page count and copy count exceed one. A driver that drops or changes these options is
|
||||
refused; select supported options or flatten grayscale into the print-ready page content.
|
||||
Without an explicit duplex request, the queue default applies. Normal color output retains
|
||||
the driver's color policy. Single-page or single-copy jobs have no collating order to check.
|
||||
|
||||
`cargo test -p craft-print` tests CUPS transport (including pipe-sized jobs and refusal),
|
||||
printer parsing, PPD options and argument mapping, and on every platform the Windows pieces that
|
||||
need no printer: the PrintTicket, the checks on the driver's validated ticket and capabilities,
|
||||
@@ -90,8 +107,8 @@ code changes, and supports manual runs. The Windows job executes the spooler reg
|
||||
physical printer fidelity still requires hardware verification.
|
||||
|
||||
Windows discovery reports driver duplex support in `Printer::two_sided` (a two-sided option in
|
||||
the driver's PrintCapabilities); CUPS leaves it unknown. Callers can warn when a Windows driver
|
||||
ignores the job’s duplex choice.
|
||||
the driver's PrintCapabilities); CUPS leaves it unknown. Explicit simplex/duplex choices that the driver
|
||||
drops are refused before submission.
|
||||
|
||||
Windows dependencies: the maintained [`winprint` fork](https://github.com/storytold/winprint.rs)
|
||||
(BSD-3-Clause, pinned to an immutable Git revision, its PDFium-downloading `pdfium` feature off) and `winsafe` (MIT) hold every Win32 and WinRT call, so the crate keeps `unsafe` forbidden;
|
||||
@@ -101,3 +118,13 @@ reqwest (with rustls) and sigstore crates as non-optional build-dependencies, us
|
||||
running `cargo deny` need a licence exception for `webpki-root-certs` (CDLA-Permissive-2.0, CA
|
||||
list data reqwest brings) and an advisory ignore for RUSTSEC-2024-0370 (`proc-macro-error`
|
||||
unmaintained, a compile-time proc macro of winprint's `fmt-derive`).
|
||||
|
||||
`craft_print::rgb` exposes the safe `craft-winprint-gdi` API for native driver settings,
|
||||
printable geometry, RGB pages and caller-composed vector strokes. Applications retain ICC
|
||||
conversion, document layout, labels and print-mark composition. The shared transport validates
|
||||
single-copy DEVMODE settings before emitting manual copies and checks GDI errors. Color policy
|
||||
is explicit: application-managed/unmanaged RGB disables DC and old-style halftone correction
|
||||
with `ICM_DONE_OUTSIDEDC`, reapplied after `StartPage`; driver-managed RGB uses `ICM_OFF`.
|
||||
Vendor driver automatic color correction remains a driver setting and requires qualification.
|
||||
Other platforms return `rgb::PrintError::Unsupported`. The Windows PDF backend and its existing
|
||||
completion dependency pin are independent of this additional MIT OR Apache-2.0 API.
|
||||
@@ -19,6 +19,13 @@ mod ticket;
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
|
||||
/// Native Windows driver settings, printable geometry and explicit RGB/GDI transport.
|
||||
/// Callers perform ICC conversion and compose page pixels/vector strokes. Other platforms
|
||||
/// return `rgb::PrintError::Unsupported`; the PDF spooler remains independent of this API.
|
||||
pub mod rgb {
|
||||
pub use craft_winprint_gdi::*;
|
||||
}
|
||||
|
||||
/// A system spooler failure. Document permissions belong to the caller.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
|
||||
@@ -806,6 +806,8 @@ pub(crate) struct Sheet {
|
||||
pub(crate) bounds: [f64; 4],
|
||||
/// `/Rotate`, 0, 90, 180 or 270.
|
||||
pub(crate) rotate: u32,
|
||||
/// Annotation flags match the renderer's visible printing behavior.
|
||||
print_annotations_safe: bool,
|
||||
}
|
||||
|
||||
impl Sheet {
|
||||
@@ -939,7 +941,22 @@ pub(crate) fn read(data: &[u8]) -> Result<Sheets<'_>, Error> {
|
||||
let rotate = here.rotate.as_ref().and_then(|r| reader.resolve(r)).and_then(|r| r.int()).unwrap_or(0).rem_euclid(360);
|
||||
// Not a multiple of 90 is invalid; such a page is shown unrotated.
|
||||
let rotate = if rotate % 90 == 0 { u32::try_from(rotate).unwrap_or(0) } else { 0 };
|
||||
sheets.push(Sheet { obj, dict, bounds, rotate });
|
||||
// Windows renders viewer visibility, not annotation print visibility. Only a plain
|
||||
// visible printable annotation is safe; flatten other flags before native submission.
|
||||
let print_annotations_safe = match get(&dict, b"Annots") {
|
||||
None | Some(Obj::Null) => true,
|
||||
Some(annotations) => match reader.resolve(annotations) {
|
||||
Some(Obj::Null) => true,
|
||||
Some(Obj::Array(items)) => items.iter().all(|item| {
|
||||
reader
|
||||
.resolve(item)
|
||||
.and_then(|a| a.dict().cloned())
|
||||
.is_some_and(|annotation| get(&annotation, b"F").and_then(|f| reader.resolve(f)).and_then(|f| f.int()) == Some(4))
|
||||
}),
|
||||
_ => false,
|
||||
},
|
||||
};
|
||||
sheets.push(Sheet { obj, dict, bounds, rotate, print_annotations_safe });
|
||||
}
|
||||
if reader.budget.exhausted {
|
||||
return Err(unreadable("the parser memory budget was exceeded"));
|
||||
@@ -1042,6 +1059,15 @@ pub(crate) fn plan(sheets: &[Sheet], paper: (f64, f64), fit: Fit) -> Result<Vec<
|
||||
const OLD_GEOMETRY: [&[u8]; 6] = [b"MediaBox", b"CropBox", b"BleedBox", b"TrimBox", b"ArtBox", b"Rotate"];
|
||||
|
||||
impl Sheets<'_> {
|
||||
/// Refuse annotation print/view visibility the Windows renderer cannot preserve.
|
||||
pub(crate) fn check_print_annotations(&self) -> Result<(), Error> {
|
||||
if self.sheets.iter().all(|s| s.print_annotations_safe) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Spool("flatten annotation print visibility into page content before native Windows printing; nothing was sent".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The PDF with `edits` appended as an incremental update (ISO 32000-1, 7.5.6): the
|
||||
/// original bytes, then the new versions of the edited pages, the content streams a
|
||||
/// reframed page wraps its own in, and a cross-reference section of the same kind as the
|
||||
@@ -1068,6 +1094,15 @@ impl Sheets<'_> {
|
||||
if get(&self.trailer, b"Encrypt").is_some() {
|
||||
return Err(refuse("it is encrypted"));
|
||||
}
|
||||
// Annotation appearances live outside /Contents. Transforming only content
|
||||
// would leave them at the old coordinates; deleting them loses printed marks.
|
||||
// Require callers to flatten before fitting until appearances can be reframed.
|
||||
if let Some(annotations) = get(&dict, b"Annots")
|
||||
&& !matches!(annotations, Obj::Null)
|
||||
&& !matches!(annotations, Obj::Array(items) if items.is_empty())
|
||||
{
|
||||
return Err(refuse("flatten page annotations before using shrink-to-fit"));
|
||||
}
|
||||
let [a, b, c, d, e, f] = frame.matrix;
|
||||
let [x, y, w, h] = frame.clip;
|
||||
let prefix = format!(
|
||||
|
||||
@@ -49,6 +49,10 @@ const TABLOID: TestPage = TestPage { media: Some([0.0, 0.0, 792.0, 1224.0]), cro
|
||||
/// cross-reference stream, as modern writers save. The root page node sets a Letter MediaBox
|
||||
/// for pages that inherit it.
|
||||
fn test_pdf(pages: &[TestPage], compressed: bool) -> Vec<u8> {
|
||||
test_pdf_with_annotations(pages, compressed, None)
|
||||
}
|
||||
|
||||
fn test_pdf_with_annotations(pages: &[TestPage], compressed: bool, annotations: Option<&str>) -> Vec<u8> {
|
||||
let n = pages.len();
|
||||
// 1 catalog, 2 page tree, 3 content, then the pages.
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + i)).collect();
|
||||
@@ -69,6 +73,9 @@ fn test_pdf(pages: &[TestPage], compressed: bool) -> Vec<u8> {
|
||||
if let Some(r) = p.rotate {
|
||||
page += &format!(" /Rotate {r}");
|
||||
}
|
||||
if let Some(annotations) = annotations {
|
||||
page += &format!(" /Annots {annotations}");
|
||||
}
|
||||
objects.push(page + " >>");
|
||||
}
|
||||
let mut pdf = b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n".to_vec();
|
||||
@@ -264,6 +271,43 @@ fn shrink_to_fit_reframes_sheets_on_the_paper() {
|
||||
assert_eq!(shapes(&sheets.update(&edits).unwrap()), [((612, 792), 0), ((612, 792), 270), ((612, 792), 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrink_to_fit_refuses_to_drop_annotation_appearances() {
|
||||
use crate::sheets::{Fit, plan, read};
|
||||
for annotations in ["[<< /Subtype /Text /Rect [36 36 72 72] /F 4 >>]", "42 0 R"] {
|
||||
let pdf = test_pdf_with_annotations(&[LETTER, LETTER_LANDSCAPE], false, Some(annotations));
|
||||
let sheets = read(&pdf).unwrap();
|
||||
let fit = plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 18.0 }).unwrap();
|
||||
assert!(matches!(sheets.update(&fit), Err(Error::Spool(ref m)) if m.contains("flatten page annotations")));
|
||||
let exact = plan(&sheets.sheets, (612.0, 792.0), Fit::Exact).unwrap();
|
||||
let rotated = sheets.update(&exact).unwrap();
|
||||
assert!(rotated.starts_with(&pdf));
|
||||
assert!(String::from_utf8_lossy(&rotated[pdf.len()..]).contains("/Annots"), "rotation retains annotations on the updated page");
|
||||
}
|
||||
for annotations in ["[]", "null"] {
|
||||
let pdf = test_pdf_with_annotations(&[LETTER, LETTER_LANDSCAPE], false, Some(annotations));
|
||||
let sheets = read(&pdf).unwrap();
|
||||
let fit = plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 18.0 }).unwrap();
|
||||
assert!(sheets.update(&fit).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotation_print_visibility_is_checked_before_native_submission() {
|
||||
for flags in [0, 4, 6, 36, 20] {
|
||||
let annotations = format!("[<< /Subtype /Square /Rect [36 36 72 72] /F {flags} >>]");
|
||||
let pdf = test_pdf_with_annotations(&[LETTER], false, Some(&annotations));
|
||||
let sheets = crate::sheets::read(&pdf).unwrap();
|
||||
assert_eq!(sheets.check_print_annotations().is_ok(), flags == 4, "flags={flags}");
|
||||
}
|
||||
for annotations in ["[]", "null"] {
|
||||
let pdf = test_pdf_with_annotations(&[LETTER], false, Some(annotations));
|
||||
assert!(crate::sheets::read(&pdf).unwrap().check_print_annotations().is_ok());
|
||||
}
|
||||
let pdf = test_pdf(&[LETTER], false);
|
||||
assert!(crate::sheets::read(&pdf).unwrap().check_print_annotations().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_encrypted_file_is_turned_but_not_reframed() {
|
||||
// An /Encrypt entry is all it takes: new content streams would have to be encrypted too.
|
||||
@@ -367,16 +411,24 @@ fn windows_jobs_reach_the_spooler() {
|
||||
}
|
||||
let listed = spool::printers().into_iter().find(|p| p.name == name);
|
||||
let mixed = test_pdf(&[LETTER, LETTER_LANDSCAPE, LETTER], true);
|
||||
let job = Job { printer: Some(name.into()), copies: 2, duplex: Some(Duplex::LongEdge), title: "mixed.pdf".into(), ..Job::default() };
|
||||
let job = Job { printer: Some(name.into()), title: "mixed.pdf".into(), ..Job::default() };
|
||||
let caller = std::thread::current().id();
|
||||
// Rc proves callers are not required to make their callback Send just because native
|
||||
// printing uses a COM worker. Counts describe PDF sheets, despite copies and duplex.
|
||||
// printing uses a COM worker. Counts describe accepted PDF sheets.
|
||||
let processed = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let observed = std::rc::Rc::clone(&processed);
|
||||
let sent = spool::submit_with_progress(&mixed, &job, &mut move |done, total| {
|
||||
assert_eq!(std::thread::current().id(), caller);
|
||||
observed.borrow_mut().push((done, total));
|
||||
});
|
||||
let mut duplex_progress = Vec::new();
|
||||
let duplex_refused = if listed.as_ref().is_some_and(|p| p.two_sided == Some(false)) {
|
||||
Some(spool::submit_with_progress(&mixed, &Job { duplex: Some(Duplex::LongEdge), ..job.clone() }, &mut |done, total| {
|
||||
duplex_progress.push((done, total));
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let tabloid = test_pdf(&[TABLOID, LETTER], false);
|
||||
let shrink = Job { printer: Some(name.into()), scaling: Scaling::ShrinkToFit, title: "shrink".into(), ..Job::default() };
|
||||
let shrunk = spool::submit(&tabloid, &shrink);
|
||||
@@ -396,7 +448,11 @@ fn windows_jobs_reach_the_spooler() {
|
||||
);
|
||||
assert!(listed.as_ref().is_some_and(|p| p.two_sided.is_some()), "the new printer is listed, its duplex support known: {listed:?}");
|
||||
assert_eq!(sent.as_deref(), Ok("spooled 3 sheet(s)"), "{sent:?}");
|
||||
if let Some(refused) = duplex_refused {
|
||||
assert!(matches!(refused, Err(Error::Spool(ref m)) if m.contains("simplex/duplex") && m.contains("nothing was sent")), "{refused:?}");
|
||||
}
|
||||
assert_eq!(*processed.borrow(), [(0, 3), (1, 3), (2, 3), (3, 3)]);
|
||||
assert!(duplex_progress.is_empty(), "driver refusal before submission has no progress");
|
||||
assert_eq!(shrunk.as_deref(), Ok("spooled 2 sheet(s)"), "{shrunk:?}");
|
||||
assert!(matches!(exact_refused, Err(Error::Spool(ref m)) if m.contains("one paper size")), "{exact_refused:?}");
|
||||
assert!(refused_progress.is_empty(), "rejection before a job starts has no progress: {refused_progress:?}");
|
||||
|
||||
@@ -171,6 +171,41 @@ pub(crate) fn verify_ticket(xml: &str, paper: Option<(u32, u32)>, landscape: boo
|
||||
Ok(size)
|
||||
}
|
||||
|
||||
/// Confirm explicit job options survived driver validation. Collation only changes output
|
||||
/// when both the document and copy count exceed one. Without a duplex request the queue's
|
||||
/// default applies; without forced grayscale the driver's normal color policy applies.
|
||||
pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize) -> Result<(), Error> {
|
||||
let document = roxmltree::Document::parse(xml).map_err(spool_error)?;
|
||||
let root = document.root_element();
|
||||
let option = |name: &str| {
|
||||
root.children()
|
||||
.find(|n| n.has_tag_name((NS_PSF, "Feature")) && named(*n, name))
|
||||
.and_then(|f| f.children().find(|n| n.has_tag_name((NS_PSF, "Option"))))
|
||||
};
|
||||
let refused = |field: &str| spool_error(format!("the printer's driver did not retain the requested {field}; nothing was sent"));
|
||||
let copies = job.copies.clamp(1, 999);
|
||||
if integer(root, "ParameterInit", "JobCopiesAllDocuments") != Some(copies) {
|
||||
return Err(refused("copy count"));
|
||||
}
|
||||
if copies > 1 && pages > 1 && !option("DocumentCollate").is_some_and(|n| named(n, if job.collate { "Collated" } else { "Uncollated" })) {
|
||||
return Err(refused("collation"));
|
||||
}
|
||||
if let Some(duplex) = job.duplex {
|
||||
let wanted = match duplex {
|
||||
Duplex::Off => "OneSided",
|
||||
Duplex::LongEdge => "TwoSidedLongEdge",
|
||||
Duplex::ShortEdge => "TwoSidedShortEdge",
|
||||
};
|
||||
if !option("JobDuplexAllDocumentsContiguously").is_some_and(|n| named(n, wanted)) {
|
||||
return Err(refused("simplex/duplex setting"));
|
||||
}
|
||||
}
|
||||
if job.grayscale && !option("PageOutputColor").is_some_and(|n| named(n, "Monochrome") || named(n, "Grayscale")) {
|
||||
return Err(refused("grayscale"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The widest unprintable margin of the paper, in micrometres, from PrintCapabilities computed
|
||||
/// for the job's ticket (`PageImageableSize`). The largest of the four, so content centred inside
|
||||
/// it is clear of every edge whichever way the driver measures. `None` when the driver doesn't say.
|
||||
@@ -262,6 +297,37 @@ mod tests {
|
||||
assert!(verify_ticket("<not xml", Some(letter), false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_driver_that_drops_explicit_job_options_is_refused() {
|
||||
let job = Job { copies: 2, duplex: Some(Duplex::LongEdge), grayscale: true, ..Job::default() };
|
||||
let xml = ticket_xml(&job, false);
|
||||
assert!(verify_job_options(&xml, &job, 3).is_ok());
|
||||
for (from, to) in
|
||||
[(">2<", ">1<"), ("psk:Collated", "psk:Uncollated"), ("psk:TwoSidedLongEdge", "psk:OneSided"), ("psk:Monochrome", "psk:Color")]
|
||||
{
|
||||
assert!(verify_job_options(&xml.replace(from, to), &job, 3).is_err(), "{from}");
|
||||
}
|
||||
assert!(verify_job_options(&xml.replace("psk:Collated", "vendor:Collated"), &job, 3).is_err(), "namespace matters");
|
||||
assert!(verify_job_options(&xml.replace("psk:Monochrome", "psk:Grayscale"), &job, 3).is_ok());
|
||||
for feature in ["DocumentCollate", "JobDuplexAllDocumentsContiguously", "PageOutputColor"] {
|
||||
let start = xml.find(&format!("<psf:Feature name=\"psk:{feature}\"")).unwrap();
|
||||
let end = start + xml[start..].find("</psf:Feature>").unwrap() + "</psf:Feature>".len();
|
||||
let mut dropped = xml.clone();
|
||||
dropped.replace_range(start..end, "");
|
||||
assert!(verify_job_options(&dropped, &job, 3).is_err(), "{feature}");
|
||||
if feature == "DocumentCollate" {
|
||||
assert!(verify_job_options(&dropped, &job, 1).is_ok(), "one page has no collating order");
|
||||
}
|
||||
}
|
||||
let default = Job::default();
|
||||
let xml = ticket_xml(&default, false);
|
||||
assert!(verify_job_options(&xml, &default, 3).is_ok());
|
||||
let simplex = Job { duplex: Some(Duplex::Off), ..Job::default() };
|
||||
assert!(verify_job_options(&ticket_xml(&simplex, false), &simplex, 3).is_ok());
|
||||
assert!(verify_job_options(&xml, &simplex, 3).is_err(), "explicit simplex must be confirmed");
|
||||
assert!(verify_job_options("<not xml", &default, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_driver_paper_named_like_the_standard_size_is_preferred() {
|
||||
let paper = |width, height, name, keyword| Paper { width, height, name, keyword };
|
||||
|
||||
@@ -38,7 +38,7 @@ pub(crate) fn printers() -> Vec<Printer> {
|
||||
|
||||
/// Whether the printer's driver takes two-sided settings: its PrintCapabilities offer a two-sided
|
||||
/// option for the feature the job's ticket sets. `None` when the capabilities can't be read. A
|
||||
/// driver without one drops two-sided from the ticket and prints its own way.
|
||||
/// driver without one cannot confirm an explicit two-sided request, which is refused before submission.
|
||||
fn two_sided(device: &PrinterDevice) -> Option<bool> {
|
||||
let capabilities = PrintCapabilities::fetch(device).ok()?;
|
||||
let duplex = OwnedName::qualified("JobDuplexAllDocumentsContiguously", NS_PSK, Some("psk"));
|
||||
@@ -126,6 +126,7 @@ fn imageable_margin(device: &PrinterDevice, ticket: &PrintTicket) -> f64 {
|
||||
/// queue: a paused queue or an offline printer keeps the job there, as with any program.
|
||||
pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usize, usize)) -> Result<String, Error> {
|
||||
let read = sheets::read(pdf)?;
|
||||
read.check_print_annotations()?;
|
||||
let count = read.sheets.len();
|
||||
let first = read.sheets.first().map(sheets::Sheet::size).ok_or_else(|| spool_error("the print-ready PDF has no pages"))?;
|
||||
let landscape = first.0 > first.1 + SAME;
|
||||
@@ -142,6 +143,8 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM
|
||||
// The worker is joined before `dir` is dropped: the spooler reads the file.
|
||||
let result = crate::progress::run(progress, move |sender| {
|
||||
let (ticket, paper) = driver_ticket(&device, job, first, landscape)?;
|
||||
let xml = std::str::from_utf8(ticket.get_xml()).map_err(spool_error)?;
|
||||
ticket::verify_job_options(xml, job, count)?;
|
||||
let (fit, paper) = match job.scaling {
|
||||
Scaling::Exact => (Fit::Exact, first),
|
||||
Scaling::ShrinkToFit => {
|
||||
@@ -185,21 +188,39 @@ mod tests {
|
||||
assert!(select_device(Vec::new(), None, None).is_err());
|
||||
}
|
||||
|
||||
/// What a real driver makes of a job's ticket: Letter, portrait, two copies, two-sided on
|
||||
/// the long edge, grayscale. Sends no job. `CRAFT_PRINT_TEST_PRINTER` names the queue;
|
||||
/// What a real driver makes of a job's ticket: Letter, portrait, two copies. Checks explicit
|
||||
/// option retention separately; unsupported options must fail before submission. Sends no job.
|
||||
/// `CRAFT_PRINT_TEST_PRINTER` names the queue;
|
||||
/// `CRAFT_PRINT_TEST_TICKET_OUTPUT` keeps the validated ticket, to see which options it kept.
|
||||
#[test]
|
||||
#[ignore = "needs CRAFT_PRINT_TEST_PRINTER naming an installed printer; sends no job"]
|
||||
fn an_installed_driver_validates_the_ticket() {
|
||||
let name = std::env::var("CRAFT_PRINT_TEST_PRINTER").unwrap();
|
||||
let device = select_device(PrinterDevice::all().unwrap(), Some(&name), None).unwrap();
|
||||
let job = Job { copies: 2, duplex: Some(crate::Duplex::LongEdge), grayscale: true, ..Job::default() };
|
||||
let job = Job { copies: 2, ..Job::default() };
|
||||
let (ticket, paper) = driver_ticket(&device, &job, (612.0, 792.0), false).unwrap();
|
||||
ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &job, 1).unwrap();
|
||||
assert_eq!(paper, (215_900, 279_400));
|
||||
eprintln!("unprintable margin: {} pt", imageable_margin(&device, &ticket));
|
||||
if let Ok(path) = std::env::var("CRAFT_PRINT_TEST_TICKET_OUTPUT") {
|
||||
std::fs::write(path, ticket.get_xml()).unwrap();
|
||||
}
|
||||
let capabilities = PrintCapabilities::fetch(&device).unwrap();
|
||||
for (feature, wanted, requested) in [
|
||||
("PageOutputColor", "Monochrome", Job { grayscale: true, ..Job::default() }),
|
||||
("JobDuplexAllDocumentsContiguously", "TwoSidedLongEdge", Job { duplex: Some(crate::Duplex::LongEdge), ..Job::default() }),
|
||||
("DocumentCollate", "Collated", Job { copies: 2, ..Job::default() }),
|
||||
] {
|
||||
let (ticket, _) = driver_ticket(&device, &requested, (612.0, 792.0), false).unwrap();
|
||||
let retained = ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &requested, 3);
|
||||
eprintln!("{feature}={wanted}: {retained:?}");
|
||||
let offered = capabilities
|
||||
.options_for_feature(OwnedName::qualified(feature, NS_PSK, Some("psk")))
|
||||
.any(|o| o.name.as_ref().is_some_and(|n| n.local_name == wanted || (feature == "PageOutputColor" && n.local_name == "Grayscale")));
|
||||
if !offered {
|
||||
assert!(retained.is_err(), "unsupported explicit option must not be silently dropped");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A real job, for checking a printer by hand (paper, or a paused queue's spool file):
|
||||
|
||||
Reference in new issue
Block a user