From 18c4d3146ac40e6b04829fe5c2f462e19d5e7cd3 Mon Sep 17 00:00:00 2001 From: Justin Capella <4584655+b1tninja@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:03:09 -0700 Subject: [PATCH 1/8] Accept a one-sided request on a queue whose driver has no duplex feature The Windows path refused any explicit duplex request whose validated PrintTicket lacked the duplex feature. The Print dialog asks for one-sided by default, so every queue without a duplex unit refused with default settings, although one-sided is all such a queue can do and nothing was dropped. A one-sided request is now satisfied when the driver's PrintCapabilities offer no duplex feature at all. Two-sided requests without the feature, a driver that offers the feature but returns a ticket without the setting, and capabilities that cannot be read are still refused. Copies, collation, colour, paper, orientation and scaling are checked as before. Co-Authored-By: Claude Sonnet 5.5 --- crates/print/src/tests/windows_native.rs | 9 ++- crates/print/src/ticket.rs | 95 +++++++++++++++++++++--- crates/print/src/windows.rs | 15 +++- 3 files changed, 103 insertions(+), 16 deletions(-) diff --git a/crates/print/src/tests/windows_native.rs b/crates/print/src/tests/windows_native.rs index 157fe68b..efb6310f 100644 --- a/crates/print/src/tests/windows_native.rs +++ b/crates/print/src/tests/windows_native.rs @@ -429,6 +429,9 @@ fn windows_jobs_reach_the_spooler() { } else { None }; + // The Print dialog's default, an explicit one-sided request, on a queue with no duplex unit. + let simplex_only = listed.as_ref().is_some_and(|p| p.two_sided == Some(false)); + let one_sided = simplex_only.then(|| spool::submit(&mixed, &Job { duplex: Some(Duplex::Off), title: "simplex".into(), ..job.clone() })); 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); @@ -451,12 +454,16 @@ fn windows_jobs_reach_the_spooler() { 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:?}"); } + if let Some(one_sided) = one_sided { + assert_eq!(one_sided.as_deref(), Ok("spooled 3 sheet(s)"), "one-sided is what a simplex-only queue does: {one_sided:?}"); + } 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:?}"); - assert_eq!(queued.as_deref().map(str::trim), Ok("mixed.pdf 3|shrink.pdf 2"), "one job each, all sheets, in the paused queue: {queued:?}"); + let expected = if simplex_only { "mixed.pdf 3|simplex.pdf 3|shrink.pdf 2" } else { "mixed.pdf 3|shrink.pdf 2" }; + assert_eq!(queued.as_deref().map(str::trim), Ok(expected), "one job each, all sheets, in the paused queue: {queued:?}"); assert!(cleaned.is_ok(), "the test printer is removed again: {cleaned:?}"); // Asking for a printer that isn't there is an error, never a job on another one. let missing = spool::submit(&mixed, &Job { printer: Some(name.into()), ..Job::default() }); diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index 5f8c914b..852368be 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -174,7 +174,13 @@ pub(crate) fn verify_ticket(xml: &str, paper: Option<(u32, u32)>, landscape: boo /// 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> { +/// +/// `duplex_offered` is what the driver's PrintCapabilities say about the duplex feature +/// ([`offers_duplex`]). A one-sided request to a driver that offers no duplex feature at all is +/// satisfied by construction (the queue can only print one side, so nothing was dropped), but a +/// two-sided request is not, and neither is a one-sided one to a driver that offers the feature +/// and returned a ticket without it. `None` (capabilities unreadable) stays strict. +pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize, duplex_offered: Option) -> Result<(), Error> { let document = roxmltree::Document::parse(xml).map_err(spool_error)?; let root = document.root_element(); let option = |name: &str| { @@ -196,7 +202,8 @@ pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize) -> Result<( Duplex::LongEdge => "TwoSidedLongEdge", Duplex::ShortEdge => "TwoSidedShortEdge", }; - if !option("JobDuplexAllDocumentsContiguously").is_some_and(|n| named(n, wanted)) { + let one_sided_only = duplex == Duplex::Off && duplex_offered == Some(false); + if !one_sided_only && !option("JobDuplexAllDocumentsContiguously").is_some_and(|n| named(n, wanted)) { return Err(refused("simplex/duplex setting")); } } @@ -206,6 +213,21 @@ pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize) -> Result<( Ok(()) } +/// Whether PrintCapabilities offer any option for the duplex feature. `None` when the document +/// cannot be read as capabilities, so callers treat the answer as unknown. +pub(crate) fn offers_duplex(capabilities_xml: &str) -> Option { + let document = roxmltree::Document::parse(capabilities_xml).ok()?; + let root = document.root_element(); + if !root.has_tag_name((NS_PSF, "PrintCapabilities")) { + return None; + } + Some( + root.children() + .filter(|n| n.has_tag_name((NS_PSF, "Feature")) && named(*n, "JobDuplexAllDocumentsContiguously")) + .any(|f| f.children().any(|n| n.has_tag_name((NS_PSF, "Option")))), + ) +} + /// 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. @@ -301,31 +323,80 @@ mod tests { 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()); + assert!(verify_job_options(&xml, &job, 3, None).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(from, to), &job, 3, None).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()); + assert!(verify_job_options(&xml.replace("psk:Collated", "vendor:Collated"), &job, 3, None).is_err(), "namespace matters"); + assert!(verify_job_options(&xml.replace("psk:Monochrome", "psk:Grayscale"), &job, 3, None).is_ok()); for feature in ["DocumentCollate", "JobDuplexAllDocumentsContiguously", "PageOutputColor"] { let start = xml.find(&format!("").unwrap() + "".len(); let mut dropped = xml.clone(); dropped.replace_range(start..end, ""); - assert!(verify_job_options(&dropped, &job, 3).is_err(), "{feature}"); + assert!(verify_job_options(&dropped, &job, 3, None).is_err(), "{feature}"); if feature == "DocumentCollate" { - assert!(verify_job_options(&dropped, &job, 1).is_ok(), "one page has no collating order"); + assert!(verify_job_options(&dropped, &job, 1, None).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()); + assert!(verify_job_options(&xml, &default, 3, None).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(" String { + let feature = if duplex { + r#""# + } else { + "" + }; + format!( + r#" +{feature} +"# + ) + } + + #[test] + fn a_driver_without_a_duplex_feature_prints_one_side_and_is_not_refused_for_it() { + let offered = |duplex| offers_duplex(&capabilities_xml(duplex)); + assert_eq!(offered(true), Some(true)); + assert_eq!(offered(false), Some(false)); + assert_eq!(offers_duplex(""), None, "another namespace is not capabilities"); + assert_eq!(offers_duplex(""# + ); + assert_eq!(offers_duplex(&empty), Some(false), "a feature with no option offers nothing"); + + let one_sided = Job { duplex: Some(Duplex::Off), ..Job::default() }; + let two_sided = Job { duplex: Some(Duplex::LongEdge), ..Job::default() }; + let kept = ticket_xml(&one_sided, false); + // The ticket a driver with no duplex feature returns: the setting is gone. + let none_kept = ticket_xml(&Job::default(), false); + + // The Print dialog's defaults (duplex Off) on a simplex-only queue. + assert!(verify_job_options(&none_kept, &one_sided, 3, offered(false)).is_ok()); + // Offered and kept. + assert!(verify_job_options(&kept, &one_sided, 3, offered(true)).is_ok()); + // Offered but the ticket lost it: the driver dropped the choice. + assert!(verify_job_options(&none_kept, &one_sided, 3, offered(true)).is_err()); + // Two-sided never rests on the missing feature. + assert!(verify_job_options(&none_kept, &two_sided, 3, offered(false)).is_err()); + // Unknown capabilities stay strict. + assert!(verify_job_options(&none_kept, &one_sided, 3, None).is_err()); + // The other options are still checked on a simplex-only queue. + let copies = Job { copies: 2, duplex: Some(Duplex::Off), ..Job::default() }; + assert!(verify_job_options(&none_kept, &copies, 3, offered(false)).is_err(), "copy count"); + let gray = Job { grayscale: true, duplex: Some(Duplex::Off), ..Job::default() }; + assert!(verify_job_options(&none_kept, &gray, 3, offered(false)).is_err(), "grayscale"); } #[test] diff --git a/crates/print/src/windows.rs b/crates/print/src/windows.rs index 45a23aa7..8802e107 100644 --- a/crates/print/src/windows.rs +++ b/crates/print/src/windows.rs @@ -45,6 +45,12 @@ fn two_sided(device: &PrinterDevice) -> Option { Some(capabilities.options_for_feature(duplex).any(|o| o.name.as_ref().is_some_and(|n| n.local_name.starts_with("TwoSided")))) } +/// What the driver's PrintCapabilities say about the duplex feature; `None` when they can't be read. +fn offered_duplex(device: &PrinterDevice) -> Option { + let xml = PrintCapabilities::fetch_xml_for_ticket(device, None).ok()?; + ticket::offers_duplex(std::str::from_utf8(&xml).ok()?) +} + /// The queue the job goes to: the one asked for, else the default. Never another one: a job /// for a queue that has gone must not come out of a different printer. fn select_device(devices: Vec, requested: Option<&str>, default: Option<&str>) -> Result { @@ -144,7 +150,10 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM 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)?; + // Only a one-sided request can be satisfied by a driver with no duplex feature, so the + // capabilities are read for that case alone; unreadable stays unknown (strict). + let duplex_offered = if job.duplex == Some(crate::Duplex::Off) { offered_duplex(&device) } else { None }; + ticket::verify_job_options(xml, job, count, duplex_offered)?; let (fit, paper) = match job.scaling { Scaling::Exact => (Fit::Exact, first), Scaling::ShrinkToFit => { @@ -199,7 +208,7 @@ mod tests { let device = select_device(PrinterDevice::all().unwrap(), Some(&name), None).unwrap(); 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(); + ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &job, 1, None).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") { @@ -212,7 +221,7 @@ mod tests { ("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); + let retained = ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &requested, 3, None); eprintln!("{feature}={wanted}: {retained:?}"); let offered = capabilities .options_for_feature(OwnedName::qualified(feature, NS_PSK, Some("psk"))) From bfbf82b360656df434bfa28cf0217976dc968bce Mon Sep 17 00:00:00 2001 From: magdale76 <16279778+magdale76@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:17:48 +0200 Subject: [PATCH 2/8] fix(print): a black and white chosen in the printer's preferences applies The Windows job ticket always named a colour: Monochrome when the job asked for grayscale, Color otherwise. Merged over the user's printing preferences, that Color replaced a black and white the user had picked in the driver's own Properties dialog, so the job came out in colour (storytold/pdfcraft#2422). The ticket now names a colour only when grayscale is asked for. Without it the driver validates the ticket from the user's preferences, as the CUPS backend and `verify_job_options` already assumed. Checked on an HP OfficeJet 250 (PCL-3 driver) with its preferences set to black and white: the validated ticket had psk:Color and the DEVMODE dmColor 2 before, psk:Monochrome and dmColor 1 after. With colour preferences it is psk:Color either way. - `the_ticket_carries_the_job_options`: no PageOutputColor without grayscale (fails on the old ticket). - `the_preferences_colour_reaches_a_job_without_grayscale` (ignored, needs CRAFT_PRINT_TEST_PRINTER; sends no job): the job's validated colour equals the preferences' colour. Fails on the old ticket with the queue set to black and white; passes on HP OfficeJet 250, Microsoft Print to PDF and OneNote. - The Windows command preview says `color=(driver default)`, like `duplex=(queue default)`. Co-Authored-By: Claude Opus 5.5 --- crates/print/README.md | 6 ++++-- crates/print/src/lib.rs | 2 +- crates/print/src/tests.rs | 2 +- crates/print/src/ticket.rs | 10 ++++++---- crates/print/src/windows.rs | 26 ++++++++++++++++++++++++++ 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/crates/print/README.md b/crates/print/README.md index 00b12689..a490efdf 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -83,8 +83,10 @@ Native Windows PDF submission checks the driver's validated copy count and expli 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. +Without an explicit duplex request, the queue default applies. Without a grayscale request the +ticket names no colour, so the colour in the user's printing preferences (Properties…, or +Windows Settings) applies, black and white included. 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 diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 8fbea52b..32c0fc6a 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -558,7 +558,7 @@ fn windows_preview(job: &Job) -> Vec { Scaling::ShrinkToFit => "shrink-to-fit", } ), - format!("color={}", if job.grayscale { "monochrome" } else { "color" }), + format!("color={}", if job.grayscale { "monochrome" } else { "(driver default)" }), ] } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index fd5499e9..2ebaa949 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -257,7 +257,7 @@ fn command_previews_do_not_require_pdf_files() { } else { assert_eq!( preview, - ["xps-print", "printer=Office (east)", "copies=2", "collate=true", "duplex=(queue default)", "scaling=exact", "color=color"] + ["xps-print", "printer=Office (east)", "copies=2", "collate=true", "duplex=(queue default)", "scaling=exact", "color=(driver default)"] ); } } diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index 5f8c914b..45b8a06c 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -43,14 +43,15 @@ pub(crate) fn ticket_xml(job: &Job, landscape: bool) -> String { } }; let collate = if job.collate { "Collated" } else { "Uncollated" }; - let color = if job.grayscale { "Monochrome" } else { "Color" }; + // Only grayscale is asked for. Without it the ticket says nothing about colour, so a black and + // white chosen in the driver's own preferences (Properties…) still applies (storytold/pdfcraft#2422). + let color = if job.grayscale { "\n " } else { "" }; let copies = job.copies.clamp(1, 999); format!( r#" {copies} - {duplex} - + {duplex}{color} "# @@ -256,10 +257,11 @@ mod tests { assert!(!xml.contains("<&>"), "the title never enters the XML"); assert!(roxmltree::Document::parse(&xml).is_ok()); let xml = ticket_xml(&Job { copies: 5000, ..Job::default() }, false); - for value in [">999<", "psk:Collated", "psk:Color", "psk:Portrait"] { + for value in [">999<", "psk:Collated", "psk:Portrait"] { assert!(xml.contains(value), "missing {value}"); } assert!(!xml.contains("JobDuplex"), "no duplex asked: the queue's default applies"); + assert!(!xml.contains("PageOutputColor"), "no grayscale asked: the driver's own colour setting applies"); assert!(ticket_xml(&Job { duplex: Some(Duplex::Off), ..Job::default() }, false).contains("psk:OneSided")); assert!(ticket_xml(&Job { duplex: Some(Duplex::LongEdge), ..Job::default() }, false).contains("psk:TwoSidedLongEdge")); assert!(!ticket_xml(&Job { printer: Some("Office \"east\" <1>".into()), ..Job::default() }, false).contains("east")); diff --git a/crates/print/src/windows.rs b/crates/print/src/windows.rs index 45a23aa7..126e6bb7 100644 --- a/crates/print/src/windows.rs +++ b/crates/print/src/windows.rs @@ -242,4 +242,30 @@ mod tests { eprintln!("{sent:?}"); assert!(sent.is_ok()); } + + /// A colour chosen in the driver's own preferences (Properties…) reaches the job when the job + /// doesn't ask for grayscale: the validated ticket has the colour of the user's preferences, + /// whatever they are (storytold/pdfcraft#2422; it was always Color before). Set the queue to + /// black and white in its preferences to see the case that failed. Sends no job. + #[test] + #[ignore = "needs CRAFT_PRINT_TEST_PRINTER naming an installed printer; sends no job"] + fn the_preferences_colour_reaches_a_job_without_grayscale() { + fn colour(ticket: &PrintTicket) -> Option { + let xml = std::str::from_utf8(ticket.get_xml()).ok()?; + let document = roxmltree::Document::parse(xml).ok()?; + let feature = document + .root_element() + .children() + .find(|n| n.has_tag_name((ticket::NS_PSF, "Feature")) && n.attribute("name").is_some_and(|a| a.ends_with(":PageOutputColor")))?; + feature.children().find(|n| n.has_tag_name((ticket::NS_PSF, "Option")))?.attribute("name").map(str::to_owned) + } + let name = std::env::var("CRAFT_PRINT_TEST_PRINTER").unwrap(); + let device = select_device(PrinterDevice::all().unwrap(), Some(&name), None).unwrap(); + let mut builder = PrintTicketBuilder::new(&device).unwrap(); + builder.merge(PrintTicket::from_xml(winprint::ticket::DEFAULT_PRINT_TICKET_XML.as_bytes().to_vec())).unwrap(); + let preferences = colour(&builder.build().unwrap()); + eprintln!("the preferences' colour: {preferences:?}"); + let (ticket, _) = driver_ticket(&device, &Job::default(), (595.28, 841.89), false).unwrap(); + assert_eq!(colour(&ticket), preferences, "a job without grayscale keeps the preferences' colour"); + } } From 00f0656e365a177a4527b98ae7a9f194a5221658 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:23:12 +0000 Subject: [PATCH 3/8] print: refuse a Windows job option only when the driver offers it and dropped it The driver's PrintCapabilities tell a driver that dropped a requested value from one that never had the feature. Grayscale on a driver with no colour option and uncollated copies on one that can't collate are accepted, like one-sided on a queue without duplex. Collated copies on a driver that can't collate are refused with their own message. The capabilities are read only when the strict check fails. --- crates/print/README.md | 8 +- crates/print/src/ticket.rs | 202 +++++++++++++++++++++++++++--------- crates/print/src/windows.rs | 24 +++-- 3 files changed, 171 insertions(+), 63 deletions(-) diff --git a/crates/print/README.md b/crates/print/README.md index a490efdf..5158584c 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -81,8 +81,12 @@ 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. +the page count and copy count exceed one. A driver that offers one of these options and drops +or changes it is refused; select supported options or flatten grayscale into the print-ready +page content. A driver without the feature, per its PrintCapabilities, is accepted when it can +only do what was asked: one-sided on a queue with no two-sided feature, grayscale on one with no +colour option, uncollated copies on one that can't collate. Two-sided on such a queue is +refused, and so are collated copies: Windows doesn't document collating for such a driver. Without an explicit duplex request, the queue default applies. Without a grayscale request the ticket names no colour, so the colour in the user's printing preferences (Properties…, or Windows Settings) applies, black and white included. Single-page or single-copy jobs have no diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index ab9f5f65..5cdce4e7 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -172,16 +172,52 @@ pub(crate) fn verify_ticket(xml: &str, paper: Option<(u32, u32)>, landscape: boo Ok(size) } +/// What a driver's PrintCapabilities offer for the options [`verify_job_options`] checks. A +/// field is `None` when the capabilities couldn't be read; that option is then checked strictly. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct Offered { + /// The two-sided feature (`JobDuplexAllDocumentsContiguously`) has any option. + pub duplex: Option, + /// The colour feature (`PageOutputColor`) has an option other than Monochrome and Grayscale. + pub color: Option, + /// The collation feature (`DocumentCollate`) has a Collated option. + pub collate: Option, +} + +/// What these PrintCapabilities offer ([`Offered`]); all unknown when the document is not +/// PrintCapabilities. +pub(crate) fn offered(capabilities_xml: &str) -> Offered { + let Ok(document) = roxmltree::Document::parse(capabilities_xml) else { return Offered::default() }; + let root = document.root_element(); + if !root.has_tag_name((NS_PSF, "PrintCapabilities")) { + return Offered::default(); + } + let options = |name: &'static str| { + root.children() + .filter(move |n| n.has_tag_name((NS_PSF, "Feature")) && named(*n, name)) + .flat_map(|f| f.children().filter(|n| n.has_tag_name((NS_PSF, "Option")))) + }; + Offered { + duplex: Some(options("JobDuplexAllDocumentsContiguously").next().is_some()), + // A vendor's own option might be colour, so only the two schema keywords count as not. + color: Some(options("PageOutputColor").any(|n| !named(n, "Monochrome") && !named(n, "Grayscale"))), + collate: Some(options("DocumentCollate").any(|n| named(n, "Collated"))), + } +} + /// 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. +/// default applies; without a grayscale request the driver's own colour setting applies. /// -/// `duplex_offered` is what the driver's PrintCapabilities say about the duplex feature -/// ([`offers_duplex`]). A one-sided request to a driver that offers no duplex feature at all is -/// satisfied by construction (the queue can only print one side, so nothing was dropped), but a -/// two-sided request is not, and neither is a one-sided one to a driver that offers the feature -/// and returned a ticket without it. `None` (capabilities unreadable) stays strict. -pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize, duplex_offered: Option) -> Result<(), Error> { +/// A driver that offers an option ([`Offered`]) and returned a ticket without the requested +/// value dropped it, and the job is refused. A driver without the feature is accepted where it +/// can only do what was asked: one-sided on a driver with no two-sided feature, grayscale on one +/// with no colour option, uncollated copies on one that can't collate. Two-sided on a simplex +/// driver is refused, and so are collated copies on a driver that can't collate: Windows doesn't +/// document collating for such a driver (the standard XPS filters make the copies, and leave +/// collated copies to the device only when its GPD or PPD has a Collate command). An unknown +/// offer (`None`) is checked strictly. +pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize, offered: Offered) -> Result<(), Error> { let document = roxmltree::Document::parse(xml).map_err(spool_error)?; let root = document.root_element(); let option = |name: &str| { @@ -195,7 +231,15 @@ pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize, duplex_offe 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")); + match (job.collate, offered.collate) { + (false, Some(false)) => {} + (true, Some(false)) => { + return Err(spool_error( + "the printer's driver can't collate copies; nothing was sent (turn collation off, or print one copy at a time)", + )); + } + _ => return Err(refused("collation")), + } } if let Some(duplex) = job.duplex { let wanted = match duplex { @@ -203,32 +247,18 @@ pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize, duplex_offe Duplex::LongEdge => "TwoSidedLongEdge", Duplex::ShortEdge => "TwoSidedShortEdge", }; - let one_sided_only = duplex == Duplex::Off && duplex_offered == Some(false); + let one_sided_only = duplex == Duplex::Off && offered.duplex == Some(false); if !one_sided_only && !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")) { + let monochrome_only = offered.color == Some(false); + if job.grayscale && !monochrome_only && !option("PageOutputColor").is_some_and(|n| named(n, "Monochrome") || named(n, "Grayscale")) { return Err(refused("grayscale")); } Ok(()) } -/// Whether PrintCapabilities offer any option for the duplex feature. `None` when the document -/// cannot be read as capabilities, so callers treat the answer as unknown. -pub(crate) fn offers_duplex(capabilities_xml: &str) -> Option { - let document = roxmltree::Document::parse(capabilities_xml).ok()?; - let root = document.root_element(); - if !root.has_tag_name((NS_PSF, "PrintCapabilities")) { - return None; - } - Some( - root.children() - .filter(|n| n.has_tag_name((NS_PSF, "Feature")) && named(*n, "JobDuplexAllDocumentsContiguously")) - .any(|f| f.children().any(|n| n.has_tag_name((NS_PSF, "Option")))), - ) -} - /// 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. @@ -325,58 +355,76 @@ mod tests { 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, None).is_ok()); + assert!(verify_job_options(&xml, &job, 3, Offered::default()).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, None).is_err(), "{from}"); + assert!(verify_job_options(&xml.replace(from, to), &job, 3, Offered::default()).is_err(), "{from}"); } - assert!(verify_job_options(&xml.replace("psk:Collated", "vendor:Collated"), &job, 3, None).is_err(), "namespace matters"); - assert!(verify_job_options(&xml.replace("psk:Monochrome", "psk:Grayscale"), &job, 3, None).is_ok()); + assert!(verify_job_options(&xml.replace("psk:Collated", "vendor:Collated"), &job, 3, Offered::default()).is_err(), "namespace matters"); + assert!(verify_job_options(&xml.replace("psk:Monochrome", "psk:Grayscale"), &job, 3, Offered::default()).is_ok()); for feature in ["DocumentCollate", "JobDuplexAllDocumentsContiguously", "PageOutputColor"] { let start = xml.find(&format!("").unwrap() + "".len(); let mut dropped = xml.clone(); dropped.replace_range(start..end, ""); - assert!(verify_job_options(&dropped, &job, 3, None).is_err(), "{feature}"); + assert!(verify_job_options(&dropped, &job, 3, Offered::default()).is_err(), "{feature}"); if feature == "DocumentCollate" { - assert!(verify_job_options(&dropped, &job, 1, None).is_ok(), "one page has no collating order"); + assert!(verify_job_options(&dropped, &job, 1, Offered::default()).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, None).is_ok()); + assert!(verify_job_options(&xml, &default, 3, Offered::default()).is_ok()); let simplex = Job { duplex: Some(Duplex::Off), ..Job::default() }; - assert!(verify_job_options(&ticket_xml(&simplex, false), &simplex, 3, None).is_ok()); - assert!(verify_job_options(&xml, &simplex, 3, None).is_err(), "explicit simplex must be confirmed"); - assert!(verify_job_options(" String { - let feature = if duplex { - r#""# - } else { - "" + /// PrintCapabilities as a driver might write them: with or without a duplex feature, and the + /// colour and collation options given (none: no such feature). + fn capabilities_xml(duplex: bool, colors: &[&str], collations: &[&str]) -> String { + let feature = |name: &str, options: &[&str]| { + if options.is_empty() { + return String::new(); + } + let options: String = options.iter().map(|o| format!(r#""#)).collect(); + format!(r#"{options}"#) }; + let duplex = feature("JobDuplexAllDocumentsContiguously", if duplex { &["psk:OneSided", "psk:TwoSidedLongEdge"] } else { &[] }); format!( - r#" -{feature} -"# + r#" +{}{duplex}{} +"#, + feature("DocumentCollate", collations), + feature("PageOutputColor", colors) ) } + const COLOR: &[&str] = &["psk:Color", "psk:Monochrome"]; + const COLLATE: &[&str] = &["psk:Collated", "psk:Uncollated"]; + + /// The ticket without one of its features, as a driver that doesn't know it returns it. + fn without(xml: &str, feature: &str) -> String { + let start = xml.find(&format!("").unwrap() + "".len(); + let mut dropped = xml.to_owned(); + dropped.replace_range(start..end, ""); + dropped + } + #[test] fn a_driver_without_a_duplex_feature_prints_one_side_and_is_not_refused_for_it() { - let offered = |duplex| offers_duplex(&capabilities_xml(duplex)); - assert_eq!(offered(true), Some(true)); - assert_eq!(offered(false), Some(false)); - assert_eq!(offers_duplex(""), None, "another namespace is not capabilities"); - assert_eq!(offers_duplex(""), Offered::default(), "another namespace is not capabilities"); + assert_eq!(super::offered(""# ); - assert_eq!(offers_duplex(&empty), Some(false), "a feature with no option offers nothing"); + assert_eq!(super::offered(&empty).duplex, Some(false), "a feature with no option offers nothing"); let one_sided = Job { duplex: Some(Duplex::Off), ..Job::default() }; let two_sided = Job { duplex: Some(Duplex::LongEdge), ..Job::default() }; @@ -393,7 +441,7 @@ mod tests { // Two-sided never rests on the missing feature. assert!(verify_job_options(&none_kept, &two_sided, 3, offered(false)).is_err()); // Unknown capabilities stay strict. - assert!(verify_job_options(&none_kept, &one_sided, 3, None).is_err()); + assert!(verify_job_options(&none_kept, &one_sided, 3, Offered::default()).is_err()); // The other options are still checked on a simplex-only queue. let copies = Job { copies: 2, duplex: Some(Duplex::Off), ..Job::default() }; assert!(verify_job_options(&none_kept, &copies, 3, offered(false)).is_err(), "copy count"); @@ -401,6 +449,58 @@ mod tests { assert!(verify_job_options(&none_kept, &gray, 3, offered(false)).is_err(), "grayscale"); } + #[test] + fn a_driver_without_colour_prints_grayscale_and_is_not_refused_for_it() { + let colors = |colors: &[&str]| offered(&capabilities_xml(true, colors, COLLATE)).color; + assert_eq!(colors(COLOR), Some(true)); + assert_eq!(colors(&["psk:Monochrome"]), Some(false), "monochrome-only"); + assert_eq!(colors(&["psk:Grayscale", "psk:Monochrome"]), Some(false)); + assert_eq!(colors(&[]), Some(false), "no colour feature"); + assert_eq!(colors(&["psk:Monochrome", "ns0000:Vivid"]), Some(true), "a vendor's option may be colour"); + + let gray = Job { grayscale: true, ..Job::default() }; + let kept = ticket_xml(&gray, false); + // A driver without the feature returns a ticket without it. + let dropped = without(&kept, "PageOutputColor"); + let color = kept.replace("psk:Monochrome", "psk:Color"); + let caps = |colors: &[&str]| offered(&capabilities_xml(true, colors, COLLATE)); + + assert!(verify_job_options(&dropped, &gray, 3, caps(&["psk:Monochrome"])).is_ok()); + assert!(verify_job_options(&dropped, &gray, 3, caps(&[])).is_ok()); + assert!(verify_job_options(&kept, &gray, 3, caps(COLOR)).is_ok()); + // A colour driver that dropped or changed the request. + assert!(verify_job_options(&color, &gray, 3, caps(COLOR)).is_err()); + assert!(verify_job_options(&dropped, &gray, 3, caps(COLOR)).is_err()); + assert!(verify_job_options(&dropped, &gray, 3, Offered::default()).is_err(), "unknown stays strict"); + } + + #[test] + fn copies_on_a_driver_that_cannot_collate_are_accepted_only_uncollated() { + let caps = |collations: &[&str]| offered(&capabilities_xml(true, COLOR, collations)); + assert_eq!(caps(COLLATE).collate, Some(true)); + assert_eq!(caps(&["psk:Uncollated"]).collate, Some(false)); + assert_eq!(caps(&[]).collate, Some(false)); + + let collated = Job { copies: 2, collate: true, ..Job::default() }; + let uncollated = Job { copies: 2, collate: false, ..Job::default() }; + let dropped = |job: &Job| without(&ticket_xml(job, false), "DocumentCollate"); + + // Uncollated copies are what a driver without collation prints. + assert!(verify_job_options(&dropped(&uncollated), &uncollated, 3, caps(&[])).is_ok()); + assert!(verify_job_options(&ticket_xml(&uncollated, false), &uncollated, 3, caps(&["psk:Uncollated"])).is_ok()); + // Collated copies are refused, saying why. + let refused = verify_job_options(&dropped(&collated), &collated, 3, caps(&[])).unwrap_err(); + assert!(refused.to_string().contains("can't collate"), "{refused}"); + // A driver that collates and dropped or changed the request. + assert!(verify_job_options(&dropped(&collated), &collated, 3, caps(COLLATE)).is_err()); + assert!(verify_job_options(&dropped(&uncollated), &uncollated, 3, caps(COLLATE)).is_err()); + let changed = ticket_xml(&collated, false).replace("psk:Collated", "psk:Uncollated"); + assert!(verify_job_options(&changed, &collated, 3, caps(COLLATE)).is_err()); + assert!(verify_job_options(&dropped(&uncollated), &uncollated, 3, Offered::default()).is_err(), "unknown stays strict"); + // One page or one copy has no collating order. + assert!(verify_job_options(&dropped(&collated), &collated, 1, caps(&[])).is_ok()); + } + #[test] fn the_driver_paper_named_like_the_standard_size_is_preferred() { let paper = |width, height, name, keyword| Paper { width, height, name, keyword }; diff --git a/crates/print/src/windows.rs b/crates/print/src/windows.rs index 1b83fd7e..765ddb55 100644 --- a/crates/print/src/windows.rs +++ b/crates/print/src/windows.rs @@ -45,10 +45,13 @@ fn two_sided(device: &PrinterDevice) -> Option { Some(capabilities.options_for_feature(duplex).any(|o| o.name.as_ref().is_some_and(|n| n.local_name.starts_with("TwoSided")))) } -/// What the driver's PrintCapabilities say about the duplex feature; `None` when they can't be read. -fn offered_duplex(device: &PrinterDevice) -> Option { - let xml = PrintCapabilities::fetch_xml_for_ticket(device, None).ok()?; - ticket::offers_duplex(std::str::from_utf8(&xml).ok()?) +/// What the driver's PrintCapabilities offer for the job options [`ticket::verify_job_options`] +/// checks; all unknown when they can't be read. +fn driver_offers(device: &PrinterDevice) -> ticket::Offered { + PrintCapabilities::fetch_xml_for_ticket(device, None) + .ok() + .and_then(|xml| String::from_utf8(xml).ok()) + .map_or_else(ticket::Offered::default, |xml| ticket::offered(&xml)) } /// The queue the job goes to: the one asked for, else the default. Never another one: a job @@ -150,10 +153,11 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM 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)?; - // Only a one-sided request can be satisfied by a driver with no duplex feature, so the - // capabilities are read for that case alone; unreadable stays unknown (strict). - let duplex_offered = if job.duplex == Some(crate::Duplex::Off) { offered_duplex(&device) } else { None }; - ticket::verify_job_options(xml, job, count, duplex_offered)?; + // The capabilities are read only when the ticket lacks a requested value: they tell a + // driver that dropped it from one that never had the feature. Unreadable stays strict. + if ticket::verify_job_options(xml, job, count, ticket::Offered::default()).is_err() { + ticket::verify_job_options(xml, job, count, driver_offers(&device))?; + } let (fit, paper) = match job.scaling { Scaling::Exact => (Fit::Exact, first), Scaling::ShrinkToFit => { @@ -208,7 +212,7 @@ mod tests { let device = select_device(PrinterDevice::all().unwrap(), Some(&name), None).unwrap(); 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, None).unwrap(); + ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &job, 1, ticket::Offered::default()).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") { @@ -221,7 +225,7 @@ mod tests { ("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, None); + let retained = ticket::verify_job_options(std::str::from_utf8(ticket.get_xml()).unwrap(), &requested, 3, ticket::Offered::default()); eprintln!("{feature}={wanted}: {retained:?}"); let offered = capabilities .options_for_feature(OwnedName::qualified(feature, NS_PSK, Some("psk"))) From 13bb99663e7f7c740100420f4c65d96a7e1c5b88 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:37:08 +0000 Subject: [PATCH 4/8] print: DocumentDuplex counts as a driver's duplex feature --- crates/print/src/ticket.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index 5cdce4e7..c072917e 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -198,7 +198,8 @@ pub(crate) fn offered(capabilities_xml: &str) -> Offered { .flat_map(|f| f.children().filter(|n| n.has_tag_name((NS_PSF, "Option")))) }; Offered { - duplex: Some(options("JobDuplexAllDocumentsContiguously").next().is_some()), + // The schema's two duplex features are alternatives; a driver offers one or the other. + duplex: Some(options("JobDuplexAllDocumentsContiguously").chain(options("DocumentDuplex")).next().is_some()), // A vendor's own option might be colour, so only the two schema keywords count as not. color: Some(options("PageOutputColor").any(|n| !named(n, "Monochrome") && !named(n, "Grayscale"))), collate: Some(options("DocumentCollate").any(|n| named(n, "Collated"))), @@ -425,6 +426,10 @@ mod tests { r#""# ); assert_eq!(super::offered(&empty).duplex, Some(false), "a feature with no option offers nothing"); + let document_duplex = format!( + r#""# + ); + assert_eq!(super::offered(&document_duplex).duplex, Some(true), "DocumentDuplex is a duplex feature too"); let one_sided = Job { duplex: Some(Duplex::Off), ..Job::default() }; let two_sided = Job { duplex: Some(Duplex::LongEdge), ..Job::default() }; From 869928d2a06a14fccd921dc9e94cd15e4ad18e18 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:36:23 -0400 Subject: [PATCH 5/8] docking: app drags in panel bodies reach other native windows (#302) * docking: route app drags in panel bodies across native windows While an app's drag that began in a panel body lasts, the owner window's input hook sends pointer moves over another of the app's windows to that window in its coordinates (PointerMoved, PointerGone, and the primary release), and the owner sees the pointer gone there. Drop sites in the target window see hover and release with no app changes. * docking: wrap the body-drag docs * docking: a stopped body-drag route takes back the events it queued * docking: route only body drags that carry a payload, and hold the owner's release A body drag reaches other windows only with an egui drag-and-drop payload or when the app calls docking::route_app_drag that frame or the last; sliders, scrollbars and other plain drags keep the owner's pointer. When the release goes to another window, the owner's own release waits for its next input, so a target drawn before the owner reads the release while egui's payload still exists. A stop never withdraws that held release. * docking: routed and held events come before a window's own input * docking: a held release waits for the main window's next pass, with the pointer events after it * docking: the owner sees the pointer go in the pass its release is held --- crates/ui/src/docking/mod.rs | 2 +- crates/ui/src/docking/native_tests.rs | 331 ++++++++++++++++++++++ crates/ui/src/docking/render/body_drag.rs | 275 ++++++++++++++++++ crates/ui/src/docking/render/cross.rs | 3 + crates/ui/src/docking/render/mod.rs | 11 + crates/ui/src/docking/render/native.rs | 49 +++- crates/ui/src/docking/surface.rs | 18 +- docs/ui-docking.md | 38 ++- 8 files changed, 719 insertions(+), 8 deletions(-) create mode 100644 crates/ui/src/docking/render/body_drag.rs diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 86198840..aff39e73 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -32,7 +32,7 @@ pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PA pub use render::{close_flyout, flyout, open_flyout}; pub use surface::{ DockArea, DockContent, DockEvent, DockHeights, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, - WindowFiles, WindowKind, begin_drag, cancel_drag, float_space, is_dragging, + WindowFiles, WindowKind, begin_drag, cancel_drag, float_space, is_dragging, route_app_drag, }; pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect, zone_shape}; pub use workspace::Workspace; diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 9003682f..388bb445 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -33,6 +33,35 @@ struct Content { pressed: Vec<(u32, PointerButton)>, /// A panel body saw hovered or dropped files. saw_files: bool, + /// The panel with an app drag source, and where it was drawn. + source: Option, + /// How the source marks its drag as one that carries something. + marks: Marks, + source_rect: Option, + /// The source's drag so far, and how often its drag stopped. + source_delta: egui::Vec2, + source_stopped: usize, + /// The source asks egui to run its window's pass again, once, the next time it draws. + discard_next: bool, + /// Panels whose bodies are app drop sites: where each saw the pointer, and where a release + /// came with the pointer inside. + drop_sites: Vec, + hovers: Vec<(u32, Pos2)>, + drops: Vec<(u32, Pos2)>, + /// Drop sites whose release found egui's drag-and-drop payload. + payload_drops: Vec, +} + +/// How a test's app drag source marks its drag. +#[derive(Default)] +enum Marks { + /// It does not: a plain egui drag, such as a slider's. + #[default] + Nothing, + /// [`super::route_app_drag`] for this area, each frame while it drags. + Route(egui::Id), + /// An egui drag-and-drop payload, its panel. + Payload, } impl DockContent for Content { @@ -52,6 +81,32 @@ impl DockContent for Content { self.count += usize::from(response.clicked()); self.counter_rect = Some(response.rect); } + if self.source == Some(*panel) { + let response = ui.allocate_response(vec2(120.0, 60.0), egui::Sense::drag()); + self.source_rect = Some(response.rect); + self.source_delta += response.drag_delta(); + self.source_stopped += usize::from(response.drag_stopped()); + if std::mem::take(&mut self.discard_next) { + ui.ctx().request_discard("the source's pass runs twice"); + } + match self.marks { + Marks::Route(area) if response.dragged() => super::route_app_drag(ui.ctx(), area), + Marks::Payload => response.dnd_set_drag_payload(*panel), + _ => {} + } + } + // As apps' drop sites do: the pointer over the body, and a release there. + if self.drop_sites.contains(panel) + && let Some(pos) = ui.ctx().pointer_hover_pos().filter(|p| ui.max_rect().contains(*p)) + { + self.hovers.push((*panel, pos)); + if ui.input(|i| i.pointer.any_released()) { + self.drops.push((*panel, pos)); + if egui::DragAndDrop::has_payload_of_type::(ui.ctx()) { + self.payload_drops.push(*panel); + } + } + } self.saw_files |= ui.input(|i| !i.raw.hovered_files.is_empty() || !i.raw.dropped_files.is_empty()); if ui.rect_contains_pointer(ui.max_rect()) { let pressed = [PointerButton::Primary, PointerButton::Secondary].into_iter().filter(|b| ui.input(|i| i.pointer.button_pressed(*b))); @@ -1824,3 +1879,279 @@ fn the_session_follows_winits_environment_rule() { assert_eq!(session(true, with(&[])), Session::Unknown); assert_eq!(session(false, with(&["WAYLAND_DISPLAY"])), Session::Other); } + +/// A driver whose panel `source` has an app drag source and whose panels `sites` are drop sites. +fn app_drag(layout: Layout, source: u32, sites: &[u32]) -> DockDriver { + let mut d = driver(layout); + (d.content.source, d.content.drop_sites, d.content.marks) = (Some(source), sites.to_vec(), Marks::Route(d.id)); + d.settle(); + d +} + +#[allow(clippy::expect_used)] +fn source(d: &DockDriver) -> Pos2 { + d.content.source_rect.expect("source drawn").center() +} + +#[test] +fn an_app_drag_from_the_main_window_reaches_a_native_windows_panel() { + // Panel 3's frame lies under window [5, 6], which takes the drop. + let mut d = app_drag(layout(true), 1, &[3, 5]); + let id = window(&d, 5).id; + let local = pos2(120.0, 200.0); + let point = d.host().windows[&d.viewport(id)].rect.min + local.to_vec2() - ORIGIN.to_vec2(); + d.hold(source(&d), point); + assert!(d.content.hovers.contains(&(5, local)), "{:?}", d.content.hovers); + d.release(point); + d.step(vec![]); + assert_eq!(d.content.drops, vec![(5, local)]); + assert!(!d.content.hovers.iter().any(|(panel, _)| *panel == 3), "{:?}", d.content.hovers); +} + +#[test] +fn a_stop_withdraws_a_routed_release_no_window_read_but_not_the_owners() { + let mut d = app_drag(layout(true), 5, &[1]); + let id = window(&d, 5).id; + let local = d.window_local(id, ORIGIN + vec2(100.0, 300.0)).unwrap(); + d.window_hold(id, source(&d), local); + // The release is queued for the main window's next pass, and the drag is cancelled first. + d.window_release(id, local); + super::cancel_drag::(&d.ctx, d.id); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.content.drops, vec![]); + // The owner still gets its own release. + assert_eq!(d.content.source_stopped, 1); +} + +#[test] +fn a_plain_egui_drag_over_a_native_window_keeps_its_moves_and_is_not_routed() { + let mut d = app_drag(layout(true), 1, &[5]); + d.content.marks = Marks::Nothing; + let id = window(&d, 5).id; + let over = d.host().windows[&d.viewport(id)].rect.center() - ORIGIN.to_vec2(); + let from = source(&d); + d.hold(from, over); + d.move_to(over + vec2(20.0, 10.0)); + let moved = over + vec2(20.0, 10.0) - from; + assert!((d.content.source_delta - moved).length() < 0.01, "{:?}, not {moved:?}", d.content.source_delta); + d.release(over); + d.step(vec![]); + assert!(d.content.hovers.is_empty(), "{:?}", d.content.hovers); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); + assert_eq!(d.content.source_stopped, 1); +} + +#[test] +fn an_egui_payload_reaches_a_window_drawn_before_the_owner() { + // The main window draws before the native window that owns the drag. + let mut d = app_drag(layout(true), 5, &[1]); + d.content.marks = Marks::Payload; + let id = window(&d, 5).id; + let local = d.window_local(id, ORIGIN + vec2(100.0, 300.0)).unwrap(); + d.window_hold(id, source(&d), local); + d.window_release(id, local); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.content.payload_drops, vec![1]); + assert!(!egui::DragAndDrop::has_any_payload(&d.ctx)); +} + +#[test] +fn an_owner_that_discards_its_release_pass_keeps_the_payload_for_the_target() { + let mut d = app_drag(layout(true), 5, &[1]); + d.content.marks = Marks::Payload; + let id = window(&d, 5).id; + let local = d.window_local(id, ORIGIN + vec2(100.0, 300.0)).unwrap(); + d.window_hold(id, source(&d), local); + // The owner's pass that holds its release runs twice. + d.content.discard_next = true; + d.window_release(id, local); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.content.payload_drops, vec![1]); +} + +#[test] +fn pointer_events_after_a_held_release_wait_behind_it() { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let point = d.host().windows[&d.viewport(id)].rect.min + vec2(120.0, 200.0) - ORIGIN.to_vec2(); + d.hold(source(&d), point); + let delta = d.content.source_delta; + // The release over the native window, then the pointer back on the source and a new press, + // in one input. + let at = source(&d); + d.step(vec![button(point, false), Event::PointerMoved(at), button(at, true)]); + assert_eq!(d.content.source_delta, delta, "no move after the release reaches the source's drag"); + d.step(vec![]); + assert!(d.ctx.input(|i| i.pointer.primary_down()), "the new press outlives the held release"); +} + +#[test] +fn the_owners_drag_stops_a_pass_after_its_release_over_another_window() { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let local = pos2(120.0, 200.0); + let point = d.host().windows[&d.viewport(id)].rect.min + local.to_vec2() - ORIGIN.to_vec2(); + d.hold(source(&d), point); + d.release(point); + assert_eq!((d.content.drops.clone(), d.content.source_stopped), (vec![(5, local)], 0)); + // The owner saw the pointer go, and holds its release and the pointer's going once. + assert_eq!(d.ctx.input(|i| i.pointer.hover_pos()), None); + let held = super::render::held_events(&d.ctx, egui::ViewportId::ROOT); + assert!(matches!(held.as_slice(), [Event::PointerButton { pressed: false, .. }, Event::PointerGone]), "{held:?}"); + d.step(vec![]); + assert_eq!(d.content.source_stopped, 1); + assert_eq!(d.ctx.dragged_id(), None); +} + +#[test] +fn a_press_right_after_a_routed_release_outlives_the_held_release() { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let point = d.host().windows[&d.viewport(id)].rect.min + vec2(120.0, 200.0) - ORIGIN.to_vec2(); + d.hold(source(&d), point); + d.release(point); + // The owner's held release comes before a press in its next input. + let at = source(&d); + d.step(vec![Event::PointerMoved(at), button(at, true)]); + assert!(d.ctx.input(|i| i.pointer.primary_down())); +} + +#[test] +fn a_routed_release_clicks_nothing_in_the_target() { + let mut d = app_drag(layout(true), 1, &[5]); + d.content.counter = Some(5); + d.step(vec![]); + let id = window(&d, 5).id; + let button = d.content.counter_rect.unwrap().center(); + let point = d.host().windows[&d.viewport(id)].rect.min + button.to_vec2() - ORIGIN.to_vec2(); + d.hold(source(&d), point); + d.release(point); + d.step(vec![]); + assert_eq!(d.content.drops, vec![(5, button)]); + assert_eq!(d.content.count, 0); +} + +#[test] +fn an_app_drag_from_a_native_window_reaches_the_main_window() { + let mut d = app_drag(layout(true), 5, &[1]); + let id = window(&d, 5).id; + // Frame [1, 2], left of the native window on the desktop. + let point = pos2(100.0, 300.0); + let local = d.window_local(id, ORIGIN + point.to_vec2()).unwrap(); + d.window_hold(id, source(&d), local); + d.window_release(id, local); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.content.drops, vec![(1, point)]); + assert!(d.content.hovers.contains(&(1, point)), "{:?}", d.content.hovers); +} + +#[test] +fn an_app_drag_between_native_windows_drops_in_the_front_one() { + // Window [7] overlaps window [5, 6] at (550, 280) on the desktop; window [8] is apart. + let mut layout = layout(true); + layout.floating.push(Floating::single(vec![7], 0, [350.0, 150.0, 240.0, 150.0])); + layout.floating.push(Floating::single(vec![8], 0, [20.0, 420.0, 240.0, 150.0])); + let overlap = pos2(550.0, 280.0); + for front in [7, 5] { + let mut d = app_drag(layout.clone(), 8, &[5, 7]); + let viewport = d.viewport(window(&d, front).id); + d.host().focus = Some(viewport); + d.step(vec![]); + d.host().focus = Some(egui::ViewportId::ROOT); + d.step(vec![]); + let (owner, target) = (window(&d, 8).id, window(&d, front).id); + let local = d.window_local(owner, overlap).unwrap(); + d.window_hold(owner, source(&d), local); + d.window_release(owner, local); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.content.drops, vec![(front, d.window_local(target, overlap).unwrap())], "front {front}"); + } +} + +#[test] +fn an_app_drag_released_outside_every_window_drops_nowhere() { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let over = d.host().windows[&d.viewport(id)].rect.center() - ORIGIN.to_vec2(); + d.hold(source(&d), over); + assert!(d.content.hovers.iter().any(|(panel, _)| *panel == 5), "{:?}", d.content.hovers); + // Left of the main window, over no window of the app: the native window sees the pointer go. + let out = pos2(-60.0, 120.0); + d.move_to(out); + d.content.hovers.clear(); + d.move_to(out); + d.release(out); + d.step(vec![]); + assert!(d.content.hovers.is_empty(), "{:?}", d.content.hovers); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); +} + +/// Hold an app drag from panel 1 over window [5, 6] and release it there. +fn drag_onto_window(d: &mut DockDriver) { + let id = window(d, 5).id; + let over = d.host().windows[&d.viewport(id)].rect.center() - ORIGIN.to_vec2(); + d.hold(source(d), over); + d.release(over); + d.step(vec![]); + d.step(vec![]); +} + +#[test] +fn no_app_drag_is_routed_where_windows_report_no_positions() { + let mut d = app_drag(layout(true), 1, &[5]); + d.host().no_position = true; + d.step(vec![]); + drag_onto_window(&mut d); + assert!(d.content.hovers.is_empty(), "{:?}", d.content.hovers); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); +} + +#[test] +fn a_blocked_native_window_gets_no_app_drag() { + let mut d = app_drag(layout(true), 1, &[5]); + d.input_blocked = true; + d.settle(); + drag_onto_window(&mut d); + assert!(d.content.hovers.is_empty(), "{:?}", d.content.hovers); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); +} + +#[test] +fn escape_and_cancel_drag_end_the_routing_of_an_app_drag() { + for cancel in [false, true] { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let over = d.host().windows[&d.viewport(id)].rect.center() - ORIGIN.to_vec2(); + d.hold(source(&d), over); + assert!(!d.content.hovers.is_empty(), "cancel {cancel}"); + if cancel { + super::cancel_drag::(&d.ctx, d.id); + d.step(vec![]); + } else { + d.key(Key::Escape); + } + d.content.hovers.clear(); + d.move_to(over); + d.release(over); + d.step(vec![]); + assert!(d.content.hovers.is_empty(), "cancel {cancel}: {:?}", d.content.hovers); + assert!(d.content.drops.is_empty(), "cancel {cancel}: {:?}", d.content.drops); + } +} + +#[test] +fn a_tab_drag_onto_a_native_window_is_no_app_drag() { + let mut d = app_drag(layout(true), 1, &[5]); + let id = window(&d, 5).id; + let over = d.host().windows[&d.viewport(id)].rect.center() - ORIGIN.to_vec2(); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let out = d.drag(tab, over); + d.step(vec![]); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 1, anchor: 5 | 6, .. }]), "{:?}", out.actions); + assert!(d.content.drops.is_empty(), "{:?}", d.content.drops); +} diff --git a/crates/ui/src/docking/render/body_drag.rs b/crates/ui/src/docking/render/body_drag.rs new file mode 100644 index 00000000..1ce80a79 --- /dev/null +++ b/crates/ui/src/docking/render/body_drag.rs @@ -0,0 +1,275 @@ +//! App drags in panel bodies across OS windows (feature `native-windows`). +//! +//! egui gives each window only its own input, and the window where the button went down gets +//! the pointer until the release. An app's drag that began in a panel body (a media item onto +//! a timeline in another window) would never show its pointer or release to the window it is +//! over. So while one that carries something lasts (an egui drag-and-drop payload, or +//! [`crate::docking::route_app_drag`]), the owner's input hook ([`super::native`]'s plugin) +//! sends each pointer move over another of the app's windows to that window, in its +//! coordinates, as `PointerMoved`, `PointerGone` when the pointer leaves it, and the release as +//! a primary `PointerButton` there. The owner sees the pointer gone while it is over another +//! window, so its own panels under that window see no hover and no drop. +//! +//! A window drawn after the owner in the same pass gets the events in that pass: the main +//! window owning the drag reaches every native window at once. The main window, and a native +//! window drawn before the owner, get them on the next pass, which the hook asks for. The +//! owner's own release then waits a pass too, so its payload outlives the target's read. + +use std::{collections::HashMap, fmt::Debug, hash::Hash}; + +use egui::{Context, Event, Id, Key, PointerButton, Pos2, RawInput, Rect, ViewportId}; + +use super::Renderer; +use super::native::{NativeInput, WindowDrag, content_of}; +use crate::docking::desktop::{Over, window_at}; +use crate::docking::{Drag, GroupDrag}; + +/// The application's windows on the desktop, as the main window last mapped them. +#[derive(Clone, Debug, Default)] +pub(super) struct Desktop { + /// The main window's content, where known. + pub main: Option, + /// Shown native windows' content, front first, and how many lie in front of the main window. + pub windows: Vec<(u64, Rect)>, + pub front: usize, + /// The viewport of each native window. + pub viewports: Vec<(u64, ViewportId)>, +} + +impl Desktop { + /// The window under the desktop point `point`, and the point in its coordinates. + fn at(&self, point: Pos2) -> Option<(ViewportId, Pos2)> { + match window_at(point, &self.windows, self.front, Some(self.main?), None) { + Over::Main(local) => Some((ViewportId::ROOT, local)), + Over::Window(window, local) => self.viewports.iter().find(|(w, _)| *w == window).map(|(_, viewport)| (*viewport, local)), + Over::Desktop => None, + } + } +} + +/// A dock area's app drag in a panel body, in the input plugin. +#[derive(Debug)] +pub(super) struct BodyRoute { + area: Id, + /// The window the drag began in, and the widget egui drags there. + owner: ViewportId, + /// [`crate::docking::Floating::id`] of the owner; `None` for the main window. + window: Option, + id: Id, + desktop: Desktop, + /// The window other than the owner that the pointer is over. + target: Option, + /// Nothing more is routed until the drag ends: released, or ended by Escape, a focus loss, + /// input blocking or [`crate::docking::cancel_drag`]. + ended: bool, +} + +/// Events waiting for each viewport's next input, with the main window's pass that queued them +/// and the dock area whose route queued each. +pub(super) type Queued = HashMap)>; + +/// Each owner's held release and the pointer events after it, with the main window's pass that +/// held them: delivered in a later pass only, never taken back. +pub(super) type Held = HashMap)>; + +fn queue(queued: &mut Queued, pass: u64, area: Id, viewport: ViewportId, event: Event) { + let (at, events) = queued.entry(viewport).or_default(); + *at = pass; + events.push((area, event)); +} + +impl BodyRoute { + /// The owner's input before egui reads it: each pointer event over another of the app's + /// windows goes to that window, and the owner sees the pointer gone. Returns whether + /// anything was queued. Where the owner reports no desktop position nothing is routed. + pub(super) fn route(&mut self, input: &mut RawInput, queued: &mut Queued, held: &mut Held, pass: u64) -> bool { + if input.viewport_id != self.owner || self.ended { + return false; + } + let Some((origin, _)) = input.viewports.get(&input.viewport_id).and_then(content_of) else { return false }; + let (mut routed, mut holding) = (false, false); + let mut events = Vec::with_capacity(input.events.len()); + for event in std::mem::take(&mut input.events) { + // Pointer events after a held release wait behind it, in order. + if holding && matches!(event, Event::PointerMoved(_) | Event::PointerButton { .. } | Event::PointerGone) { + held.entry(self.owner).or_default().1.push(event); + continue; + } + let (pos, release) = match &event { + Event::PointerMoved(pos) => (*pos, None), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers } => (*pos, Some(*modifiers)), + _ => { + events.push(event); + continue; + } + }; + if self.ended { + events.push(event); + continue; + } + let over = pos.is_finite().then(|| self.desktop.at(origin + pos.to_vec2())).flatten().filter(|(v, _)| *v != self.owner); + let target = over.map(|(viewport, _)| viewport); + if let Some(left) = self.target.filter(|left| Some(*left) != target) { + queue(queued, pass, self.area, left, Event::PointerGone); + routed = true; + } + self.target = target; + if let Some((viewport, local)) = over { + queue(queued, pass, self.area, viewport, Event::PointerMoved(local)); + if let Some(modifiers) = release { + let release = Event::PointerButton { pos: local, button: PointerButton::Primary, pressed: false, modifiers }; + queue(queued, pass, self.area, viewport, release); + // The owner's own release waits for its input in a later pass of the main + // window (not a discarded pass's rerun), so its drag, and an egui + // drag-and-drop payload, last until a window drawn before it has read the + // release. A stop never takes it back, or the button would stay down. + let (at, held_events) = held.entry(self.owner).or_default(); + *at = pass; + held_events.extend([event, Event::PointerGone]); + holding = true; + routed = true; + (self.ended, self.target) = (true, None); + events.push(Event::PointerGone); + continue; + } + routed = true; + events.push(Event::PointerGone); + } else { + events.push(event); + } + if release.is_some() { + (self.ended, self.target) = (true, None); + } + } + input.events = events; + routed + } +} + +/// End `area`'s routing: the window the pointer was over sees it go. `remove`: the drag is +/// over, so another may start. Otherwise the routing was stopped (Escape, a focus loss, input +/// blocking, [`crate::docking::cancel_drag`]), and events it queued that no window has read +/// yet are taken back, so a release queued before the stop reaches no window. +fn end_route(ctx: &Context, area: Id, remove: bool) { + let queued = ctx.with_plugin(|input: &mut NativeInput| { + let route = input.routes.get_mut(&area)?; + let mut gone: Vec = route.target.take().into_iter().collect(); + route.ended = true; + if remove { + input.routes.remove(&area); + } else { + for (viewport, (_, events)) in &mut input.queued { + let before = events.len(); + events.retain(|(from, _)| *from != area); + if events.len() != before && !gone.contains(viewport) { + gone.push(*viewport); + } + } + } + for viewport in &gone { + queue(&mut input.queued, input.pass, area, *viewport, Event::PointerGone); + } + Some(!gone.is_empty()) + }); + if queued.flatten() == Some(true) { + ctx.request_repaint_of(ViewportId::ROOT); + } +} + +/// [`crate::docking::cancel_drag`]: an app drag in a panel body goes on, but no longer reaches +/// other windows. +pub(in crate::docking) fn cancel_body_route(ctx: &Context, area: Id) { + end_route(ctx, area, false); +} + +impl Renderer<'_, P> { + /// In the main window, once the desktop is mapped and before native windows draw: give the + /// routing the map, and end it where it cannot go on. `native`: viewports are native. + pub(super) fn map_body_route(&mut self, ctx: &Context, native: bool) { + let desktop = self.desktop(); + let owner = ctx.with_plugin(|input: &mut NativeInput| { + let route = input.routes.get_mut(&self.id)?; + route.desktop = desktop; + Some(route.window) + }); + // Windows are no longer native, or the owner closed. + let natives = &self.cross.viewports; + if owner.flatten().is_some_and(|window| !native || window.is_some_and(|w| !natives.iter().any(|(id, _)| *id == w))) { + end_route(ctx, self.id, true); + } + if native { + self.route_body_drag(ctx, None); + } + } + + fn desktop(&self) -> Desktop { + Desktop { main: self.cross.main, windows: self.cross.windows.clone(), front: self.cross.front, viewports: self.cross.viewports.clone() } + } + + /// After the window drawing now (`window`; `None` for the main window) drew its panels' + /// bodies: start routing an app drag that began in one of them, and end it when the drag + /// is over. An Escape in any of the app's windows, a focus loss and input blocking end it. + pub(super) fn route_body_drag(&mut self, ctx: &Context, window: Option) { + let (area, viewport, dragged) = (self.id, ctx.viewport_id(), ctx.dragged_id()); + let route = ctx.with_plugin(|input: &mut NativeInput| input.routes.get(&area).map(|r| (r.owner, r.id))).flatten(); + let stops = self.input_blocked || self.cross.focus_lost || ctx.input(|i| i.key_pressed(Key::Escape)); + match route { + Some((owner, id)) if owner == viewport && dragged != Some(id) => end_route(ctx, area, true), + Some(_) if stops => end_route(ctx, area, false), + Some(_) => {} + None => { + let Some(id) = dragged.filter(|id| !stops && self.starts_in_body(ctx, *id)) else { return }; + let route = BodyRoute { area, owner: viewport, window, id, desktop: self.desktop(), target: None, ended: false }; + ctx.with_plugin(|input: &mut NativeInput| input.routes.insert(area, route)); + } + } + } + + /// Whether egui's drag of `id` in the window drawing now is the app's and carries something: + /// pressed with the primary button in a panel body, none of the area's own controls or + /// drags, and with an egui drag-and-drop payload or [`crate::docking::route_app_drag`] + /// called this frame or the last. Other drags (sliders, scrollbars, text selection, canvas + /// pans) keep the owner's pointer. + fn starts_in_body(&self, ctx: &Context, id: Id) -> bool { + let payload = ctx.plugin_opt::().is_some() && egui::DragAndDrop::has_any_payload(ctx); + let main = ctx.cumulative_pass_nr_for(ViewportId::ROOT); + let asked = ctx.data(|d| d.get_temp::(app_drag_key(self.id))).is_some_and(|at| at + 1 >= main); + if !payload && !asked { + return false; + } + let (area, viewport) = (self.id, ctx.viewport_id()); + let (down, press) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.press_origin())); + let (pressed, owned, dock) = ctx.data(|d| { + let pressed = d.get_temp::(body_press_key(area, viewport)); + let owned = d.get_temp::>(area.with("owned-interactions")).is_some_and(|ids| ids.contains(&id)) + || d.get_temp::(area.with("pointer-resize")) == Some(id); + let dock = d.get_temp::>(area.with("drag")).is_some() + || d.get_temp::>(area.with("group-drag")).is_some() + || d.get_temp::(area.with("window-drag")).is_some(); + (pressed, owned, dock) + }); + down && press.is_some() && pressed == press && !owned && !dock && !self.owned_interactions.contains(&id) + } +} + +/// The main window's pass in which the app last called [`crate::docking::route_app_drag`]. +fn app_drag_key(area: Id) -> Id { + area.with("route-app-drag") +} + +/// [`crate::docking::route_app_drag`]. +pub(in crate::docking) fn mark_app_drag(ctx: &Context, area: Id) { + let main = ctx.cumulative_pass_nr_for(ViewportId::ROOT); + ctx.data_mut(|d| d.insert_temp(app_drag_key(area), main)); +} + +/// The events held for `viewport`'s input in a later pass. +#[cfg(test)] +pub(in crate::docking) fn held_events(ctx: &Context, viewport: ViewportId) -> Vec { + ctx.with_plugin(|input: &mut NativeInput| input.held.get(&viewport).map(|(_, events)| events.clone())).flatten().unwrap_or_default() +} + +/// Where the last press in one of the area's panel bodies in `viewport` began. +pub(super) fn body_press_key(area: Id, viewport: ViewportId) -> Id { + area.with(("body-press", viewport)) +} diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs index 4516da33..65cf3337 100644 --- a/crates/ui/src/docking/render/cross.rs +++ b/crates/ui/src/docking/render/cross.rs @@ -104,6 +104,8 @@ pub(in crate::docking) struct Cross

{ pub held: bool, /// The panel whose body is under the pointer in the native window drawing now. pub pointed: Option

, + /// The viewport of each native window drawn this frame. + pub viewports: Vec<(u64, egui::ViewportId)>, } impl

Default for Cross

{ @@ -122,6 +124,7 @@ impl

Default for Cross

{ docks: false, held: false, pointed: None, + viewports: Vec::new(), } } } diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 276dc451..8c433ac5 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -6,6 +6,8 @@ use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions, dock_model::Place}; +#[cfg(feature = "native-windows")] +mod body_drag; #[cfg(feature = "native-windows")] mod cross; mod dock; @@ -22,6 +24,10 @@ pub(in crate::docking) use { native::{APPKIT_CORNER, MACOS_CLOSE, Session, appkit_resize_areas, close_rects, floats_shown, resize_bands, session, wayland}, }; +#[cfg(all(test, feature = "native-windows"))] +pub(in crate::docking) use body_drag::held_events; +#[cfg(feature = "native-windows")] +pub(in crate::docking) use body_drag::{cancel_body_route, mark_app_drag}; #[cfg(feature = "native-windows")] pub(in crate::docking) use native::{InputHook, set_input_hook}; @@ -371,6 +377,11 @@ impl Renderer<'_, P> { // Any button: a canvas pans with the middle button and opens menus with the secondary. if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.any_pressed()) { self.output.focused = Some(panel.clone()); + #[cfg(feature = "native-windows")] + if let Some(press) = child.input(|i| i.pointer.press_origin()) { + let key = body_drag::body_press_key(self.id, child.ctx().viewport_id()); + child.ctx().data_mut(|d| d.insert_temp(key, press)); + } } #[cfg(feature = "native-windows")] if child.rect_contains_pointer(rect) { diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index a3c30526..017add67 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -5,7 +5,8 @@ //! application is activated. Where egui embeds viewports (wasm, tests) windows stay //! in-window at their in-window rect, and so do they on Wayland, unless the app turns native //! windows on ([`crate::docking::DockArea::native_windows`]). Tabs, groups and whole windows -//! drag between OS windows (see `cross.rs`). +//! drag between OS windows (see `cross.rs`), and so do the app's drags in panel bodies +//! (`body_drag.rs`). use std::{fmt::Debug, hash::Hash}; @@ -15,6 +16,7 @@ use egui::{ }; use super::Renderer; +use super::body_drag::{BodyRoute, Held, Queued}; use super::cross::{DragPointer, Surface}; use crate::docking::chrome::{ChromeStyle, TitleBar}; pub(in crate::docking) use crate::docking::placement::BAR_HEIGHT; @@ -159,7 +161,7 @@ fn differs(a: [f32; 4], b: [f32; 4]) -> bool { /// Desktop position of a viewport's content, and its offset from the window's outer /// position, where the window system reports both. -fn content_of(info: &ViewportInfo) -> Option<(Pos2, Vec2)> { +pub(super) fn content_of(info: &ViewportInfo) -> Option<(Pos2, Vec2)> { let (outer, inner) = (info.outer_rect?, info.inner_rect?); (outer.min.is_finite() && inner.min.is_finite()).then(|| (inner.min, inner.min - outer.min)) } @@ -251,7 +253,9 @@ impl Renderer<'_, P> { let origin = self.cross.main.map(|m| m.min); self.record_pointer(ctx, None, origin, None); } + self.cross.viewports = natives.iter().map(|(w, _, viewport)| (w.id, *viewport)).collect(); } + self.map_body_route(ctx, native); for (window, rect, viewport) in natives { if let Some(key) = self.native_window(ctx, window, rect, viewport, visible) { shown.push(key); @@ -546,6 +550,7 @@ impl Renderer<'_, P> { } } else { self.paint_cross(ui, &mut surface); + self.route_body_drag(ui.ctx(), Some(window.id)); self.forward_events(ui); self.report_files(ui, window, viewport); } @@ -801,12 +806,21 @@ impl Renderer<'_, P> { } /// Input hooks of dock areas for their native windows, run by egui before it reads each -/// window's input. eframe runs `App::raw_input_hook` for the main window only. +/// window's input. eframe runs `App::raw_input_hook` for the main window only. App drags in +/// panel bodies cross windows here too (`body_drag.rs`). #[derive(Default)] -struct NativeInput { +pub(super) struct NativeInput { hooks: std::collections::HashMap>, /// The dock area of each native window shown this frame. windows: std::collections::HashMap, + /// Each dock area's app drag in a panel body. + pub routes: std::collections::HashMap, + /// Pointer events routed to each window, for its next input. + pub queued: Queued, + /// Owners' held releases, for their input in a later main-window pass. + pub held: Held, + /// The main window's passes so far. + pub pass: u64, } pub(in crate::docking) type InputHook = Box; @@ -817,6 +831,31 @@ impl egui::Plugin for NativeInput { } fn input_hook(&mut self, ctx: &Context, input: &mut RawInput) { + let viewport = input.viewport_id; + if viewport == ViewportId::ROOT { + self.pass += 1; + // Every window draws in the main window's pass: events for one that has not drawn + // since the last pass are for a window that is gone. + let pass = self.pass; + self.queued.retain(|_, (at, _)| *at + 1 >= pass); + self.held.retain(|_, (at, _)| *at + 1 >= pass); + } + // Taken before routing, which may queue more for this window's next input. A held + // release waits for a later main-window pass: a discarded pass reruns within its own. + let waiting = self.queued.remove(&viewport); + let held = if self.held.get(&viewport).is_some_and(|(at, _)| *at < self.pass) { self.held.remove(&viewport) } else { None }; + let mut routed = false; + for route in self.routes.values_mut() { + routed |= route.route(input, &mut self.queued, &mut self.held, self.pass); + } + // Before the window's own events: they happened earlier, and a held release must not + // end a press that follows it. + let earlier = + held.into_iter().flat_map(|(_, events)| events).chain(waiting.into_iter().flat_map(|(_, events)| events.into_iter().map(|(_, e)| e))); + input.events.splice(0..0, earlier); + if routed { + ctx.request_repaint_of(ViewportId::ROOT); + } if let Some(hook) = self.windows.get(&input.viewport_id).and_then(|area| self.hooks.get(area)) { (hook.lock())(ctx, input); } @@ -826,7 +865,7 @@ impl egui::Plugin for NativeInput { /// Set or clear the input hook of the dock area `area` for this frame, and forget its native /// windows until they show again. pub(in crate::docking) fn set_input_hook(ctx: &Context, area: Id, hook: Option) { - if hook.is_some() && ctx.plugin_opt::().is_none() { + if ctx.plugin_opt::().is_none() { ctx.add_plugin(NativeInput::default()); } ctx.with_plugin(|input: &mut NativeInput| { diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 3e994ebc..77fecb91 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -702,7 +702,8 @@ pub fn is_dragging(ctx: &Context, area_id: Id) /// Discard pending tab/group gestures, a native window's bar drag and an actively dragged /// renderer-owned control, in the main window now and in a native window when it next draws. -/// Body/custom-header/menu gestures and other areas are untouched. +/// Body/custom-header/menu gestures and other areas are untouched, except that an app drag in a +/// panel body no longer reaches other OS windows (feature `native-windows`). /// Ownership is recorded as controls render; before an initial render or drag threshold there /// may be no active egui drag to cancel. Keyboard focus is unchanged. Nothing is restored from /// before the drag: floating and native windows go where the next layout drawn has them. @@ -730,6 +731,21 @@ pub fn cancel_drag(ctx: &Context, area_id: Id) if owned { ctx.stop_dragging(); } + #[cfg(feature = "native-windows")] + super::render::cancel_body_route(ctx, area_id); +} + +/// Mark the app's drag in a panel body of the area `area_id` as one that carries something, so +/// it reaches the window under the pointer (feature `native-windows`; elsewhere it does +/// nothing). A drag with an egui drag-and-drop payload needs no call. Call it each frame while +/// the drag lasts, from the panel body or before the area shows; a call starts the routing +/// for this frame and the next, and a routing once started lasts until the drag ends. Drags +/// that are never marked (sliders, scrollbars, text selection, canvas pans) stay in their window. +pub fn route_app_drag(ctx: &Context, area_id: Id) { + #[cfg(feature = "native-windows")] + super::render::mark_app_drag(ctx, area_id); + #[cfg(not(feature = "native-windows"))] + let _ = (ctx, area_id); } /// One mutable application adapter owns bodies, menus, shaped text and per-panel policy, diff --git a/docs/ui-docking.md b/docs/ui-docking.md index eef8db51..c27f2291 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -511,7 +511,8 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho shown panel, since the window system may send no pointer moves during a file drag), the pointer's last position in the window, and the window's `hovered_files` and `dropped_files`. Files on the main window stay in its own `RawInput`. -- **Drags.** Tabs, groups and whole windows drag between the app's OS windows. +- **Drags.** Tabs, groups and whole windows drag between the app's OS windows, and the app's + own drags in panel bodies reach the window under the pointer. - *Ownership.* The window where the button went down owns the drag: the window system sends it the pointer's moves and the release while the button is held, inside the window or not. So the pointer leaving a window (`PointerGone`) is not a release. A drag ends on release, @@ -554,6 +555,41 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho works inside its own window, and a release outside it floats a new window that the compositor places. Moving a window hands the move to the compositor, which never docks it. + - *App drags in panel bodies.* A drag the app starts in a panel body can reach the window + under the pointer, so a drop site there works as it does inside one window. + - *Which drags.* An egui drag of one of the app's widgets, pressed with the primary button + in a panel body, that carries something: egui's `DragAndDrop` holds a payload + (`dnd_set_drag_payload`), or the app calls `docking::route_app_drag(ctx, area_id)` each + frame while the drag lasts, from the body or before the area shows. An app that keeps its + payload in the context's data makes that call. Other drags (sliders, scrollbars, text + selection, canvas pans) stay in their window and keep getting its pointer. A call counts + for its frame and the next; once routing starts, it lasts until the drag ends. + - *Events.* While the drag lasts, the owner's pointer moves over another of the app's + windows (found as for tab drags, front window first) go to that window in its own + coordinates: `PointerMoved` while the pointer is over it, `PointerGone` when it leaves, + and the release as a primary `PointerButton` with `pressed: false`. That window sees + `pointer_hover_pos` and `any_released` as if the window system had sent them. It sees no + press, so `any_down` stays false there and nothing in it is clicked. The owner sees the + pointer gone while it is over another window, so its panels under a palette see no hover + and no drop. + - *Input path.* Events go in through the area's egui input plugin, before any widget reads + the input; the app calls nothing more. In a native window this runs before the app's + `native_input_hook`. eframe runs the main window's `App::raw_input_hook` earlier, so that + hook sees the main window's input without the routed events. + - *Release timing.* A window drawn after the owner in the same pass gets the events in that + pass (from the main window, every native window does). The main window, and a native + window drawn before the owner, get them on the next pass, which is requested. When the + release goes to another window, the owner gets its own release, and the pointer events + that came after it, in the main window's next pass (a rerun of a discarded pass does not + count), so its drag stops (`drag_stopped`) then. egui clears a `DragAndDrop` payload when the owner sees the + release, so the target always reads its release while the payload is there. An app that + keeps its own payload clears it no earlier than the owner's release. + - *Limits.* Nothing is routed where desktop positions are unknown (Wayland, + `native_windows(false)`, embedded viewports) or while input is blocked. Escape in any of + the app's windows, a focus loss, `cancel_drag` and a replaced layout stop the routing: + the window under the pointer sees it go and gets no release it has not read yet. The drag + goes on in its own window until the button comes up, and the owner still gets its + release. On the web and wherever egui embeds viewports, nothing changes: floating windows stay in the page at their in-window rect, `native` and the monitor fields are kept but unused, the input From e95bc0d2a26df57f7c45fd153f661f88a13d470f Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:10:52 -0400 Subject: [PATCH 6/8] docking: blocking input hands the keyboard to the main window (#306) * docking: blocking input hands the keyboard from a native window to the main window * docking: the keyboard goes to the main window only if a native window still has it after a short wait --- crates/ui/src/docking/native_tests.rs | 44 +++++++++++++++++++++++++++ crates/ui/src/docking/render/mod.rs | 33 ++++++++++++++++++++ crates/ui/src/docking/surface.rs | 5 ++- docs/ui-docking.md | 8 +++-- 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 388bb445..5fd79bdc 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -1710,6 +1710,50 @@ fn a_focused_field_and_a_held_press_stay_inert_across_a_block() { assert_eq!(d.content.count, 0); } +#[test] +fn blocking_input_hands_focus_from_a_native_window_to_the_main_window() { + let focus_main = |d: &DockDriver| { + d.host().commands.iter().filter(|(v, c)| *v == egui::ViewportId::ROOT && matches!(c, ViewportCommand::Focus)).count() + }; + let steps = |d: &mut DockDriver, n: usize| { + for _ in 0..n { + d.step(vec![]); + } + }; + let focused_float = || { + let mut d = blocked_driver(); + let viewport = d.viewport(id_of(&d)); + d.host().focus = Some(viewport); + d.step(vec![]); + d.input_blocked = true; + d + }; + // A dialog opened from a floating panel needs the keys there, in the main window: once, + // after a short wait. + let mut d = focused_float(); + steps(&mut d, 2); + assert_eq!(focus_main(&d), 0, "not yet: an OS dialog may still take focus"); + steps(&mut d, 30); + assert_eq!(focus_main(&d), 1); + // An OS dialog took focus meanwhile: no window of the area has it, so nothing is sent. + let mut d = focused_float(); + d.step(vec![]); + d.host().focus = None; + steps(&mut d, 30); + assert_eq!(focus_main(&d), 0); + // Blocking ended before the wait was over. + let mut d = focused_float(); + d.step(vec![]); + d.input_blocked = false; + steps(&mut d, 30); + assert_eq!(focus_main(&d), 0); + // With the main window focused already, nothing is sent. + let mut d = blocked_driver(); + d.input_blocked = true; + steps(&mut d, 30); + assert_eq!(focus_main(&d), 0); +} + #[test] fn blocking_input_cancels_a_drag_in_progress() { // A tab drag a native window owns. diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 8c433ac5..b669502c 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -31,6 +31,39 @@ pub(in crate::docking) use body_drag::{cancel_body_route, mark_app_drag}; #[cfg(feature = "native-windows")] pub(in crate::docking) use native::{InputHook, set_input_hook}; +/// How long after blocking starts a native window that still has the keyboard hands it to the +/// main window: an OS dialog the app opens with the modal (a file dialog) takes focus a little +/// later, and a Focus sent before then could raise the main window over it. +#[cfg(feature = "native-windows")] +const BLOCKED_FOCUS_WAIT: f64 = 0.2; + +/// While input is blocked (`starts`: from this frame), a native window of the dock area `area` +/// with the keyboard would drop the keys the modal needs, so once, after +/// [`BLOCKED_FOCUS_WAIT`], the main window, where the modal is, takes the keyboard if one still +/// has it. If focus went elsewhere meanwhile, such as to an OS dialog, nothing is sent. +#[cfg(feature = "native-windows")] +pub(in crate::docking) fn focus_main_from_native

(ctx: &Context, area: Id, layout: &Layout

, blocked: bool, starts: bool) { + let (key, now) = (area.with("blocked-focus"), ctx.input(|i| i.time)); + if !blocked { + ctx.data_mut(|d| d.remove::(key)); + return; + } + if starts { + ctx.data_mut(|d| d.insert_temp(key, now)); + } + let Some(since) = ctx.data(|d| d.get_temp::(key)) else { return }; + if now - since < BLOCKED_FOCUS_WAIT { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(BLOCKED_FOCUS_WAIT - (now - since))); + return; + } + ctx.data_mut(|d| d.remove::(key)); + let focused = + ctx.input(|i| layout.floating.iter().any(|w| i.raw.viewports.get(&native_viewport(area, w.id)).and_then(|v| v.focused) == Some(true))); + if focused { + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus); + } +} + /// The viewport of the native window `window` of the dock area `area`. #[cfg(feature = "native-windows")] pub(in crate::docking) fn native_viewport(area: Id, window: u64) -> egui::ViewportId { diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 77fecb91..c1af45d2 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -1136,9 +1136,12 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { // Blocking starts while the pointer may be down: a drag that went on would act on its // release, under the modal. let blocked_key = self.id.with("input-blocked"); - if self.input_blocked && !ui.ctx().data(|d| d.get_temp::(blocked_key).unwrap_or(false)) { + let starts = self.input_blocked && !ui.ctx().data(|d| d.get_temp::(blocked_key).unwrap_or(false)); + if starts { cancel_drag::

(ui.ctx(), self.id); } + #[cfg(feature = "native-windows")] + super::render::focus_main_from_native(ui.ctx(), self.id, layout, self.input_blocked, starts); ui.ctx().data_mut(|d| d.insert_temp(blocked_key, self.input_blocked)); cancel_drag_if_replaced(ui.ctx(), self.id, layout); release_moved_tab_focus(ui.ctx(), self.id, layout); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index c27f2291..9123eaec 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -491,8 +491,12 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho main window is raised last, in front of the normal windows and of a palette the user clicked. - *Edges.* Turning blocking on cancels a drag in progress, and a widget of a native window - loses keyboard focus. A zoom gesture still shows in egui's `InputState::zoom_delta`, which - cannot be cleared; a widget that zooms only while hovered ignores it. egui smooths wheel + loses keyboard focus. If a native window has the OS keyboard focus then (the modal was + opened from it, say by a double-click in a floating panel) and still has it 0.2 s later, + the main window gets `ViewportCommand::Focus`, so the modal gets the keys. The wait lets an + OS dialog opened with the modal (a file dialog) take focus first; then nothing is sent. A + zoom gesture still shows in egui's `InputState::zoom_delta`, which cannot be cleared; a + widget that zooms only while hovered ignores it. egui smooths wheel scrolling, so a scroll area in a native window may still scroll for about 100 ms after blocking ends, with the remainder of a wheel turn made just before it began. - Floating windows drawn in the main window need nothing: the modal covers them. From 2869b8cc1077530bd07a7e192fb2b0e1304a1354 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:11:16 -0400 Subject: [PATCH 7/8] print: collated copies on a Windows driver that can't collate repeat the pages in one job (#305) --- crates/print/README.md | 7 +- crates/print/src/lib.rs | 4 +- crates/print/src/sheets.rs | 232 ++++++++++++++++++++--- crates/print/src/tests/pdf_bounds.rs | 115 ++++++++++- crates/print/src/tests/windows_native.rs | 24 +-- crates/print/src/ticket.rs | 70 ++++++- crates/print/src/windows.rs | 34 +++- 7 files changed, 432 insertions(+), 54 deletions(-) diff --git a/crates/print/README.md b/crates/print/README.md index 5158584c..76694445 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -86,7 +86,12 @@ or changes it is refused; select supported options or flatten grayscale into the page content. A driver without the feature, per its PrintCapabilities, is accepted when it can only do what was asked: one-sided on a queue with no two-sided feature, grayscale on one with no colour option, uncollated copies on one that can't collate. Two-sided on such a queue is -refused, and so are collated copies: Windows doesn't document collating for such a driver. +refused. Windows doesn't document collating for a driver that can't collate, so collated copies +on one are a single job of one copy that holds the document's pages once per copy; on two-sided +paper an odd page count gets a blank page after each copy but the last, so every copy starts on +a sheet of its own. Pages with annotations and encrypted PDFs can't be repeated and are refused, +and so are queues set to print several pages per sheet, to bind, or to a two-sided option outside +the Print Schema's. Without an explicit duplex request, the queue default applies. Without a grayscale request the ticket names no colour, so the colour in the user's printing preferences (Properties…, or Windows Settings) applies, black and white included. Single-page or single-copy jobs have no diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index b6cf1093..d1702473 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -483,7 +483,9 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result { /// [`submit`] with Windows sheet-processing progress: `(processed, sheets)` before the first /// sheet and after each sheet is successfully added to the native print job. Counts refer to -/// source PDF sheets, regardless of copies or duplex. CUPS reports no progress. +/// source PDF sheets, regardless of copies or duplex, except for collated copies on a driver +/// that can't collate: the job then holds every copy's sheets, and counts them all. CUPS reports +/// no progress. /// /// The callback runs synchronously on the thread calling this function, need not be `Send`, /// and should return promptly. It is never retained. All sheets processed is not terminal diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index bbb4c7c7..ee119602 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -31,6 +31,8 @@ const MAX_TOKENS: usize = 16 << 20; const MAX_OBJECT_NODES: usize = 32_000; const MAX_OBJECT_TOKENS: usize = 1 << 20; const MAX_SECTIONS: usize = 256; +/// The most the page objects of repeated copies ([`Repeat`]) may take, in bytes. +const MAX_REPEATED: usize = 64 << 20; /// A page without a MediaBox (invalid, but readers accept it) is US Letter. const DEFAULT_MEDIA: [f64; 4] = [0.0, 0.0, 612.0, 792.0]; /// Sheet sizes this close, in points (0.18 mm), are the same size. @@ -814,12 +816,18 @@ pub(crate) struct Sheet { /// The page object (number, generation); `None` for a page written inside its parent. obj: Option<(u32, u16)>, dict: Dict, + /// The MediaBox, inherited or not. + media: [f64; 4], /// The visible box (CropBox within MediaBox), in the page's own unrotated space. pub(crate) bounds: [f64; 4], + /// `/Resources` inherited from the page tree; `None` when the page has its own or none. + resources: Option>, /// `/Rotate`, 0, 90, 180 or 270. pub(crate) rotate: u32, /// Annotation flags match the renderer's visible printing behavior. print_annotations_safe: bool, + /// `/Annots` lists at least one annotation. + annotated: bool, } impl Sheet { @@ -835,6 +843,7 @@ pub(crate) struct Sheets<'a> { data: &'a [u8], pub(crate) sheets: Vec, trailer: Dict, + catalog: Dict, /// The newest cross-reference section is a stream (so is the update's). xref_stream: bool, startxref: usize, @@ -848,6 +857,7 @@ struct Inherited { media: Option>, crop: Option>, rotate: Option>, + resources: Option>, } /// Read the sheets of a print-ready PDF. @@ -905,10 +915,14 @@ pub(crate) fn read(data: &[u8]) -> Result, Error> { _ => None, }; let mut dict = reader.resolve(&node).and_then(|d| d.dict().cloned()).ok_or_else(|| unreadable("a page is missing"))?; + // A null value is the same as no entry (ISO 32000-1, 7.3.9), so it inherits. + let own = |key: &[u8]| get(&dict, key).filter(|v| **v != Obj::Null).cloned().map(Arc::new); + let parent_resources = inherited.resources.clone(); let here = Inherited { - media: get(&dict, b"MediaBox").cloned().map(Arc::new).or(inherited.media), - crop: get(&dict, b"CropBox").cloned().map(Arc::new).or(inherited.crop), - rotate: get(&dict, b"Rotate").cloned().map(Arc::new).or(inherited.rotate), + media: own(b"MediaBox").or(inherited.media), + crop: own(b"CropBox").or(inherited.crop), + rotate: own(b"Rotate").or(inherited.rotate), + resources: own(b"Resources").or(inherited.resources), }; let kids = get(&dict, b"Kids").cloned().and_then(|k| reader.resolve(&k)); let is_node = get(&dict, b"Type").and_then(Obj::name) == Some(b"Pages") || (get(&dict, b"Type").is_none() && kids.is_some()); @@ -955,20 +969,31 @@ pub(crate) fn read(data: &[u8]) -> Result, Error> { let rotate = if rotate % 90 == 0 { u32::try_from(rotate).unwrap_or(0) } else { 0 }; // 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, + let (print_annotations_safe, annotated) = match get(&dict, b"Annots") { + None | Some(Obj::Null) => (true, false), 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, + Some(Obj::Null) => (true, false), + 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)) + }), + !items.is_empty(), + ), + _ => (false, true), }, }; - sheets.push(Sheet { obj, dict, bounds, rotate, print_annotations_safe }); + // A reference to a missing object is null too. Only the cross-reference table is + // looked at: resolving a shared Resources dictionary for every page would parse it again. + let own_resources = match get(&dict, b"Resources") { + None | Some(Obj::Null) => false, + Some(Obj::Ref(num, _)) => reader.entries.contains_key(num), + Some(_) => true, + }; + let resources = if own_resources { None } else { parent_resources }; + sheets.push(Sheet { obj, dict, media, bounds, resources, rotate, print_annotations_safe, annotated }); } if reader.budget.exhausted { return Err(unreadable("the parser memory budget was exceeded")); @@ -976,7 +1001,7 @@ pub(crate) fn read(data: &[u8]) -> Result, Error> { if sheets.is_empty() { return Err(Error::Spool("the print-ready PDF has no pages".into())); } - Ok(Sheets { data, sheets, trailer, xref_stream, startxref: start, size }) + Ok(Sheets { data, sheets, trailer, catalog, xref_stream, startxref: start, size }) } /// How sheets of another size than the paper are printed. @@ -1067,6 +1092,37 @@ pub(crate) fn plan(sheets: &[Sheet], paper: (f64, f64), fit: Fit) -> Result Error { + Error::Spool(format!( + "the printer's driver can't collate copies, and they can't be repeated in one job: {why}; nothing was sent (turn collation off, or print one copy at a time)" + )) +} + +fn has_annotations(dict: &Dict) -> bool { + get(dict, b"Annots").is_some_and(|a| !matches!(a, Obj::Null) && !matches!(a, Obj::Array(items) if items.is_empty())) +} + +fn rect_obj(r: [f64; 4]) -> Obj { + Obj::Array(r.into_iter().map(num_obj).collect()) +} + +/// Starts object `num` in an update, recording where it is. +fn new_object(out: &mut Vec, num: u32, generation: u16, written: &mut Vec<(u32, u16, usize)>) { + written.push((num, generation, out.len())); + out.extend_from_slice(format!("{num} {generation} obj\n").as_bytes()); +} + /// Boxes a reframed page drops: they were in the old geometry. const OLD_GEOMETRY: [&[u8]; 6] = [b"MediaBox", b"CropBox", b"BleedBox", b"TrimBox", b"ArtBox", b"Rotate"]; @@ -1084,7 +1140,12 @@ impl Sheets<'_> { /// 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 /// newest one, chained to it with `/Prev`. - pub(crate) fn update(&self, edits: &[Edit]) -> Result, Error> { + /// + /// With `repeat`, the edited pages are not written in place: the update adds a page tree of + /// its own, every copy's pages as new page objects that share the original content and + /// resources, and a new version of the catalog pointing to it. Each new page carries the + /// boxes, rotation and resources it inherited, since its parent is the new tree. + pub(crate) fn update(&self, edits: &[Edit], repeat: Option) -> Result, Error> { let refuse = |why: &str| Error::Spool(format!("the print-ready PDF can't be fitted to the paper: {why}")); let mut out = self.data.to_vec(); if !out.ends_with(b"\n") { @@ -1093,13 +1154,10 @@ impl Sheets<'_> { let mut next = self.size; // Each new object's number, generation and offset. let mut written: Vec<(u32, u16, usize)> = Vec::new(); - fn new_object(out: &mut Vec, num: u32, generation: u16, written: &mut Vec<(u32, u16, usize)>) { - written.push((num, generation, out.len())); - out.extend_from_slice(format!("{num} {generation} obj\n").as_bytes()); - } + let repeat = repeat.filter(|r| r.copies > 1); + let mut edited = HashMap::new(); for edit in edits { let sheet = self.sheets.get(edit.sheet).ok_or_else(|| refuse("no such sheet"))?; - let (num, generation) = sheet.obj.ok_or_else(|| refuse("a page is not an object of its own"))?; let mut dict = sheet.dict.clone(); dict.retain(|(k, _)| k != b"Rotate"); if let Some(frame) = &edit.frame { @@ -1109,10 +1167,7 @@ impl Sheets<'_> { // 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()) - { + if has_annotations(&dict) { return Err(refuse("flatten page annotations before using shrink-to-fit")); } let [a, b, c, d, e, f] = frame.matrix; @@ -1155,8 +1210,131 @@ impl Sheets<'_> { dict.push((b"Contents".to_vec(), Obj::Array(contents))); } dict.push((b"Rotate".to_vec(), Obj::Num(edit.rotate.to_string().into_bytes()))); - new_object(&mut out, num, generation, &mut written); - write_dict(&mut out, &dict); + if repeat.is_some() { + edited.insert(edit.sheet, dict); + } else { + let (num, generation) = sheet.obj.ok_or_else(|| refuse("a page is not an object of its own"))?; + new_object(&mut out, num, generation, &mut written); + write_dict(&mut out, &dict); + out.extend_from_slice(b"\nendobj\n"); + } + } + if let Some(Repeat { copies, two_sided }) = repeat { + let refuse = not_repeated; + // Strings are encrypted with keys made from their object's number; a page copied + // to a new number would lose them. + if get(&self.trailer, b"Encrypt").is_some() { + return Err(refuse("the PDF is encrypted")); + } + // An annotation belongs to one page; whether a renderer draws it on others is unknown. + if self.sheets.iter().any(|s| s.annotated) { + return Err(refuse("flatten page annotations first")); + } + let Some(&Obj::Ref(root, root_generation)) = get(&self.trailer, b"Root") else { + return Err(refuse("the document catalog is not an object of its own")); + }; + let blank = two_sided && self.sheets.len() % 2 == 1; + let per_copy = self.sheets.len() + usize::from(blank); + let count = usize::try_from(copies) + .ok() + .and_then(|c| per_copy.checked_mul(c)) + .map(|n| n - usize::from(blank)) + .filter(|&n| n <= MAX_PAGES) + .ok_or_else(|| refuse("too many pages"))?; + let tree = next; + next = next.checked_add(1).ok_or_else(|| refuse("too many objects"))?; + let parent = (b"Parent".to_vec(), Obj::Ref(tree, 0)); + // Bytes written once, and bytes written once per copy (a page, its xref entry and kid). + let (mut once, mut each) = (0usize, 0usize); + let within = + |once: usize, each: usize| each.checked_mul(copies as usize).and_then(|n| n.checked_add(once)).is_some_and(|n| n <= MAX_REPEATED); + // Inherited inline resources are written once, as an object of their own, and every + // page that inherited them refers to it. + let mut shared: HashMap<*const Obj, Obj> = HashMap::new(); + let mut bodies: Vec> = Vec::with_capacity(per_copy); + let mut last = Dict::new(); + for (i, sheet) in self.sheets.iter().enumerate() { + let mut dict = edited.remove(&i).unwrap_or_else(|| sheet.dict.clone()); + dict.retain(|(k, _)| k != b"Parent"); + // The boxes as read, not the inherited objects: those can be anything. + for (key, value) in [(&b"MediaBox"[..], rect_obj(sheet.media)), (b"CropBox", rect_obj(sheet.bounds))] { + if get(&dict, key).is_none_or(|v| *v == Obj::Null) { + dict.retain(|(k, _)| k != key); + dict.push((key.to_vec(), value)); + } + } + if get(&dict, b"Rotate").is_none_or(|v| *v == Obj::Null) { + dict.retain(|(k, _)| k != b"Rotate"); + dict.push((b"Rotate".to_vec(), Obj::Num(sheet.rotate.to_string().into_bytes()))); + } + if let Some(resources) = &sheet.resources { + let value = match &**resources { + Obj::Ref(..) => Obj::clone(resources), + _ => match shared.get(&Arc::as_ptr(resources)) { + Some(r) => r.clone(), + None => { + let num = next; + next = next.checked_add(1).ok_or_else(|| refuse("too many objects"))?; + let at = out.len(); + new_object(&mut out, num, 0, &mut written); + write_obj(&mut out, resources); + out.extend_from_slice(b"\nendobj\n"); + once += out.len() - at; + if !within(once, each) { + return Err(refuse("the pages are too large to repeat")); + } + shared.insert(Arc::as_ptr(resources), Obj::Ref(num, 0)); + Obj::Ref(num, 0) + } + }, + }; + dict.retain(|(k, _)| k != b"Resources"); + dict.push((b"Resources".to_vec(), value)); + } + dict.push(parent.clone()); + let mut body = Vec::new(); + write_dict(&mut body, &dict); + each += body.len() + 64; + if !within(once, each) { + return Err(refuse("the pages are too large to repeat")); + } + bodies.push(body); + last = dict; + } + if blank { + let mut page = vec![(b"Type".to_vec(), Obj::Name(b"Page".to_vec())), parent.clone(), (b"Resources".to_vec(), Obj::Dict(Vec::new()))]; + for key in [&b"MediaBox"[..], b"CropBox", b"Rotate"] { + if let Some(value) = get(&last, key) { + page.push((key.to_vec(), value.clone())); + } + } + let mut body = Vec::new(); + write_dict(&mut body, &page); + bodies.push(body); + } + let mut kids = Vec::with_capacity(count); + for body in bodies.iter().cycle().take(count) { + let num = next; + next = next.checked_add(1).ok_or_else(|| refuse("too many objects"))?; + new_object(&mut out, num, 0, &mut written); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + kids.push(Obj::Ref(num, 0)); + } + new_object(&mut out, tree, 0, &mut written); + let node = vec![ + (b"Type".to_vec(), Obj::Name(b"Pages".to_vec())), + (b"Kids".to_vec(), Obj::Array(kids)), + (b"Count".to_vec(), Obj::Num(count.to_string().into_bytes())), + ]; + write_dict(&mut out, &node); + out.extend_from_slice(b"\nendobj\n"); + let mut catalog = self.catalog.clone(); + // Page labels and the open action name pages of the old tree. + catalog.retain(|(k, _)| k != b"Pages" && k != b"PageLabels" && k != b"OpenAction"); + catalog.push((b"Pages".to_vec(), Obj::Ref(tree, 0))); + new_object(&mut out, root, root_generation, &mut written); + write_dict(&mut out, &catalog); out.extend_from_slice(b"\nendobj\n"); } if written.is_empty() { diff --git a/crates/print/src/tests/pdf_bounds.rs b/crates/print/src/tests/pdf_bounds.rs index 76b1409a..b78a006d 100644 --- a/crates/print/src/tests/pdf_bounds.rs +++ b/crates/print/src/tests/pdf_bounds.rs @@ -65,7 +65,7 @@ fn reframe_preserves_single_direct_and_indirect_content_streams() { for (name, contents) in [("single", "3 0 R"), ("direct-array", "[3 0 R]"), ("indirect-array", "5 0 R")] { let original = fixture(contents, compressed, ""); let sheets = read(&original).unwrap(); - let updated = sheets.update(&plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 18.0 }).unwrap()).unwrap(); + let updated = sheets.update(&plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 18.0 }).unwrap(), None).unwrap(); let sheets = read(&updated).unwrap(); let contents = get(&sheets.sheets[0].dict, b"Contents").unwrap().array().unwrap(); assert_eq!(contents.len(), 3); @@ -171,7 +171,7 @@ fn reframe_keeps_array_order_and_treats_null_as_an_empty_page() { for (contents, expected) in [("[3 0 R 3 0 R]", 4), ("null", 2)] { let original = fixture(contents, false, ""); let sheets = read(&original).unwrap(); - let updated = sheets.update(&plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 0.0 }).unwrap()).unwrap(); + let updated = sheets.update(&plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 0.0 }).unwrap(), None).unwrap(); let sheets = read(&updated).unwrap(); let contents = get(&sheets.sheets[0].dict, b"Contents").unwrap().array().unwrap(); assert_eq!(contents.len(), expected); @@ -283,3 +283,114 @@ fn an_oversized_inherited_media_box_is_not_copied_for_every_page() { assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); assert_eq!(sheets.sheets.len(), pages); } + +/// A classic-xref PDF of these objects, numbered from 1. +fn classic(objects: &[String]) -> Vec { + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes()); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + pdf +} + +/// Three Letter pages that inherit their MediaBox and Resources; the third is turned by its own +/// `/Rotate 90`, and carries `extra` in its dictionary. +fn three_pages(extra: &str) -> Vec { + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R /PageMode /UseNone >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 612 792] /Resources << /ProcSet [/PDF] >> >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 7 0 R >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /Contents 8 0 R /Rotate 90 {extra} >>"), + ]; + objects.extend((0..3).map(|_| "<< /Length 0 >>\nstream\n\nendstream".to_string())); + classic(&objects) +} + +#[test] +fn collated_copies_repeat_the_pages_with_a_blank_back_between_odd_copies() { + let pdf = three_pages(""); + let sheets = read(&pdf).unwrap(); + // The turned page comes back upright in a portrait job, in every copy. + let edits = plan(&sheets.sheets, (612.0, 792.0), Fit::Exact).unwrap(); + let content = |n: u32| Some(Obj::Ref(n, 0)); + for (two_sided, expected) in [ + (true, vec![content(6), content(7), content(8), None, content(6), content(7), content(8)]), + (false, vec![content(6), content(7), content(8), content(6), content(7), content(8)]), + ] { + let updated = sheets.update(&edits, Some(Repeat { copies: 2, two_sided })).unwrap(); + let repeated = read(&updated).unwrap(); + let contents: Vec> = repeated.sheets.iter().map(|s| get(&s.dict, b"Contents").cloned()).collect(); + assert_eq!(contents, expected); + for sheet in &repeated.sheets { + assert_eq!((sheet.size(), sheet.rotate % 180), ((612.0, 792.0), 0), "{sheet:?}"); + // What the old parent gave each page is the page's own now. + assert!(get(&sheet.dict, b"Resources").is_some()); + assert!(sheet.obj.is_some_and(|(num, _)| num >= sheets.size), "every copy is a new page object"); + } + assert_eq!(get(&repeated.catalog, b"PageMode"), get(&sheets.catalog, b"PageMode"), "the rest of the catalog is kept"); + // The inherited resources are written once and shared. + let resources: Vec<_> = repeated.sheets.iter().filter(|s| get(&s.dict, b"Contents").is_some()).map(|s| get(&s.dict, b"Resources")).collect(); + assert!(matches!(resources[0], Some(Obj::Ref(..))) && resources.iter().all(|r| *r == resources[0]), "{resources:?}"); + } + // An even page count needs no blank; one copy is the document as it is. + let even = classic(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]); + let repeated = read(&even).unwrap().update(&[], Some(Repeat { copies: 3, two_sided: true })).unwrap(); + assert_eq!(read(&repeated).unwrap().sheets.len(), 6); + assert_eq!(sheets.update(&[], Some(Repeat { copies: 1, two_sided: true })).unwrap(), pdf); +} + +#[test] +fn collated_copies_that_cannot_be_repeated_are_refused() { + let repeat = Some(Repeat { copies: 2, two_sided: false }); + let annotated = three_pages("/Annots [6 0 R]"); + let refused = read(&annotated).unwrap().update(&[], repeat).unwrap_err(); + assert!(refused.to_string().contains("flatten page annotations"), "{refused}"); + let pdf = three_pages(""); + let sheets = read(&pdf).unwrap(); + let refused = sheets.update(&[], Some(Repeat { copies: 50_000, two_sided: false })).unwrap_err(); + assert!(refused.to_string().contains("too many pages"), "{refused}"); + let encrypted = Sheets { trailer: vec![(b"Encrypt".to_vec(), Obj::Ref(9, 0))], ..read(&pdf).unwrap() }; + assert!(encrypted.update(&[], repeat).unwrap_err().to_string().contains("encrypted")); +} + +#[test] +fn a_null_or_missing_entry_inherits_and_an_empty_annotation_list_is_none() { + let repeat = Some(Repeat { copies: 2, two_sided: false }); + for extra in ["/Resources null", "/Resources 99 0 R", "/Annots null", "/Annots []"] { + let pdf = three_pages(extra); + let updated = read(&pdf).unwrap().update(&[], repeat).unwrap(); + let repeated = read(&updated).unwrap(); + let resources: Vec<_> = repeated.sheets.iter().map(|s| get(&s.dict, b"Resources")).collect(); + assert!(matches!(resources[0], Some(Obj::Ref(..))) && resources.iter().all(|r| *r == resources[0]), "{extra}: {resources:?}"); + } +} + +#[test] +fn large_inherited_resources_are_written_once_for_every_copy() { + let pages = 4_000; + let procset = vec!["/PDF"; 25_000].join(" "); + let kids: Vec = (0..pages).map(|i| format!("{} 0 R", i + 3)).collect(); + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 612 792] /Resources << /ProcSet [{procset}] >> >>", kids.join(" ")), + ]; + objects.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R >>".to_string())); + let pdf = classic(&objects); + let updated = read(&pdf).unwrap().update(&[], Some(Repeat { copies: 3, two_sided: false })).unwrap(); + assert!(updated.len() < pdf.len() + (4 << 20), "{} bytes", updated.len()); + assert_eq!(read(&updated).unwrap().sheets.len(), 3 * pages); +} diff --git a/crates/print/src/tests/windows_native.rs b/crates/print/src/tests/windows_native.rs index f25f0cfb..9762366c 100644 --- a/crates/print/src/tests/windows_native.rs +++ b/crates/print/src/tests/windows_native.rs @@ -188,7 +188,7 @@ fn mixed_orientations_turn_to_the_first_sheets() { let sheets = read(&pdf).unwrap(); let edits = plan(&sheets.sheets, (612.0, 792.0), Fit::Exact).unwrap(); assert_eq!(edits.len(), 1, "only the landscape sheet changes"); - let updated = sheets.update(&edits).unwrap(); + let updated = sheets.update(&edits, None).unwrap(); assert!(updated.starts_with(&pdf), "an incremental update: the original bytes stay as they were"); assert_eq!( shapes(&updated), @@ -205,13 +205,13 @@ fn mixed_orientations_turn_to_the_first_sheets() { // A landscape first sheet makes it a landscape job: the portrait sheets turn the other way. let pdf = test_pdf(&[LETTER_LANDSCAPE, TestPage { rotate: Some(180), ..LETTER }], false); let sheets = read(&pdf).unwrap(); - let updated = sheets.update(&plan(&sheets.sheets, (792.0, 612.0), Fit::Exact).unwrap()).unwrap(); + let updated = sheets.update(&plan(&sheets.sheets, (792.0, 612.0), Fit::Exact).unwrap(), None).unwrap(); assert_eq!(shapes(&updated), [((792, 612), 0), ((792, 612), 270)], "180 + 90"); // One shape already: nothing to do, and the job is the PDF as it came. let pdf = test_pdf(&[LETTER, LETTER], true); let sheets = read(&pdf).unwrap(); assert!(plan(&sheets.sheets, (612.0, 792.0), Fit::Exact).unwrap().is_empty()); - assert_eq!(sheets.update(&[]).unwrap(), pdf); + assert_eq!(sheets.update(&[], None).unwrap(), pdf); } #[test] @@ -235,7 +235,7 @@ fn shrink_to_fit_reframes_sheets_on_the_paper() { let pdf = test_pdf(&[TABLOID], true); let sheets = read(&pdf).unwrap(); let edits = plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 18.0 }).unwrap(); - let updated = sheets.update(&edits).unwrap(); + let updated = sheets.update(&edits, None).unwrap(); assert_eq!(shapes(&updated), [((612, 792), 0)]); assert_eq!(frame_of(&updated, pdf.len()), "q 0.6176 0 0 0.6176 61.4118 18 cm 0 0 792 1224 re W n"); let tail = String::from_utf8_lossy(&updated[pdf.len()..]).into_owned(); @@ -252,7 +252,7 @@ fn shrink_to_fit_reframes_sheets_on_the_paper() { assert_eq!(edits[0].frame.as_ref().unwrap().matrix, [1.0, 0.0, 0.0, 1.0, 126.0, 144.0], "never enlarged"); assert_eq!(edits[1].frame.as_ref().unwrap().matrix, [1.0, 0.0, 0.0, 1.0, 56.0, 96.0]); assert_eq!(edits[1].frame.as_ref().unwrap().clip, [100.0, 100.0, 300.0, 400.0]); - assert_eq!(shapes(&sheets.update(&edits).unwrap()), [((612, 792), 0), ((612, 792), 0)]); + assert_eq!(shapes(&sheets.update(&edits, None).unwrap()), [((612, 792), 0), ((612, 792), 0)]); // The paper's own size: shrunk into the printable area when the printer has margins, as // CUPS' auto-fit does, and left alone when it prints to the edge. @@ -268,7 +268,7 @@ fn shrink_to_fit_reframes_sheets_on_the_paper() { let edits = plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 0.0 }).unwrap(); assert_eq!(edits.iter().map(|e| (e.sheet, e.rotate)).collect::>(), [(1, 270), (2, 0)]); assert_eq!(edits[0].frame.as_ref().unwrap().media, (792.0, 612.0), "the paper in the page's unrotated space"); - assert_eq!(shapes(&sheets.update(&edits).unwrap()), [((612, 792), 0), ((612, 792), 270), ((612, 792), 0)]); + assert_eq!(shapes(&sheets.update(&edits, None).unwrap()), [((612, 792), 0), ((612, 792), 270), ((612, 792), 0)]); } #[test] @@ -278,9 +278,9 @@ fn shrink_to_fit_refuses_to_drop_annotation_appearances() { 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"))); + assert!(matches!(sheets.update(&fit, None), 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(); + let rotated = sheets.update(&exact, None).unwrap(); assert!(rotated.starts_with(&pdf)); assert!(String::from_utf8_lossy(&rotated[pdf.len()..]).contains("/Annots"), "rotation retains annotations on the updated page"); } @@ -288,7 +288,7 @@ fn shrink_to_fit_refuses_to_drop_annotation_appearances() { 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()); + assert!(sheets.update(&fit, None).is_ok()); } } @@ -315,11 +315,11 @@ fn an_encrypted_file_is_turned_but_not_reframed() { let encrypted = replaced(&pdf, "/Root 1 0 R >>", "/Root 1 0 R /Encrypt << /Filter /Standard >> >>"); let sheets = crate::sheets::read(&encrypted).unwrap(); let turned = crate::sheets::plan(&sheets.sheets, (612.0, 792.0), crate::sheets::Fit::Exact).unwrap(); - let updated = sheets.update(&turned).unwrap(); + let updated = sheets.update(&turned, None).unwrap(); assert_eq!(shapes(&updated), [((612, 792), 0), ((612, 792), 270)]); assert!(String::from_utf8_lossy(&updated[encrypted.len()..]).contains("/Encrypt"), "the new trailer keeps /Encrypt"); let shrunk = crate::sheets::plan(&sheets.sheets, (612.0, 792.0), crate::sheets::Fit::Shrink { margin: 18.0 }).unwrap(); - assert!(matches!(sheets.update(&shrunk), Err(Error::Spool(ref m)) if m.contains("encrypted"))); + assert!(matches!(sheets.update(&shrunk, None), Err(Error::Spool(ref m)) if m.contains("encrypted"))); } #[test] @@ -348,7 +348,7 @@ fn unreadable_sheets_are_errors_never_panics() { if let Ok(sheets) = crate::sheets::read(&v) && let Ok(edits) = crate::sheets::plan(&sheets.sheets, (612.0, 792.0), crate::sheets::Fit::Shrink { margin: 18.0 }) { - let _ = sheets.update(&edits); + let _ = sheets.update(&edits, None); } } } diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index c072917e..cd6913cd 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -206,6 +206,45 @@ pub(crate) fn offered(capabilities_xml: &str) -> Offered { } } +/// Whether a `pages`-page job asks for collated copies. +pub(crate) fn collated_copies(job: &Job, pages: usize) -> bool { + job.collate && job.copies.clamp(1, 999) > 1 && pages > 1 +} + +/// Whether collated copies are made by repeating the document in one job with one copy on the +/// ticket: the job asks for them and the driver can't collate. +pub(crate) fn repeats_collated(job: &Job, pages: usize, offered: Offered) -> bool { + collated_copies(job, pages) && offered.collate == Some(false) +} + +/// Whether the validated one-copy ticket of a repeated job ([`crate::sheets::Repeat`]) prints on +/// both sides. Refused when the copies would not each start on a sheet of their own: a duplex +/// option other than the schema's, several pages per sheet, or binding (booklets included). +pub(crate) fn repeated_sides(xml: &str) -> Result { + let document = roxmltree::Document::parse(xml).map_err(spool_error)?; + let features = |names: &'static [&'static str]| { + document.root_element().children().filter(move |n| n.has_tag_name((NS_PSF, "Feature")) && names.iter().any(|name| named(*n, name))) + }; + fn option<'a, 'i>(feature: roxmltree::Node<'a, 'i>) -> Option> { + feature.children().find(|n| n.has_tag_name((NS_PSF, "Option"))) + } + let mut two_sided = false; + for feature in features(&["JobDuplexAllDocumentsContiguously", "DocumentDuplex"]) { + match option(feature) { + Some(o) if named(o, "OneSided") => {} + Some(o) if named(o, "TwoSidedLongEdge") || named(o, "TwoSidedShortEdge") => two_sided = true, + _ => return Err(crate::sheets::not_repeated("the queue's two-sided setting is not one of the standard ones")), + } + } + if features(&["JobNUpAllDocumentsContiguously", "DocumentNUp"]).any(|f| integer(f, "ScoredProperty", "PagesPerSheet").is_some_and(|n| n > 1)) { + return Err(crate::sheets::not_repeated("the queue prints several pages per sheet")); + } + if features(&["JobBindAllDocuments", "DocumentBinding"]).any(|f| !option(f).is_some_and(|o| named(o, "None"))) { + return Err(crate::sheets::not_repeated("the queue binds the pages")); + } + Ok(two_sided) +} + /// 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 a grayscale request the driver's own colour setting applies. @@ -216,8 +255,9 @@ pub(crate) fn offered(capabilities_xml: &str) -> Offered { /// with no colour option, uncollated copies on one that can't collate. Two-sided on a simplex /// driver is refused, and so are collated copies on a driver that can't collate: Windows doesn't /// document collating for such a driver (the standard XPS filters make the copies, and leave -/// collated copies to the device only when its GPD or PPD has a Collate command). An unknown -/// offer (`None`) is checked strictly. +/// collated copies to the device only when its GPD or PPD has a Collate command). The job then +/// repeats the document instead ([`repeats_collated`]) and asks for one copy. An unknown offer +/// (`None`) is checked strictly. pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize, offered: Offered) -> Result<(), Error> { let document = roxmltree::Document::parse(xml).map_err(spool_error)?; let root = document.root_element(); @@ -502,6 +542,11 @@ mod tests { let changed = ticket_xml(&collated, false).replace("psk:Collated", "psk:Uncollated"); assert!(verify_job_options(&changed, &collated, 3, caps(COLLATE)).is_err()); assert!(verify_job_options(&dropped(&uncollated), &uncollated, 3, Offered::default()).is_err(), "unknown stays strict"); + // The job repeats the document instead of asking for collated copies. + assert!(repeats_collated(&collated, 3, caps(&[]))); + assert!(!repeats_collated(&collated, 3, caps(COLLATE)) && !repeats_collated(&collated, 3, Offered::default())); + assert!(!repeats_collated(&uncollated, 3, caps(&[])) && !repeats_collated(&collated, 1, caps(&[]))); + assert!(!repeats_collated(&Job { copies: 1, ..collated.clone() }, 3, caps(&[]))); // One page or one copy has no collating order. assert!(verify_job_options(&dropped(&collated), &collated, 1, caps(&[])).is_ok()); } @@ -582,4 +627,25 @@ mod tests { assert_eq!(job_filename(""), "Print.pdf"); assert_eq!(job_filename("Отчёт"), "Отчёт.pdf"); } + + #[test] + fn a_repeated_job_reads_its_sides_and_refuses_layouts_that_mix_copies() { + let xml = ticket_xml(&Job { duplex: Some(Duplex::LongEdge), ..Job::default() }, false); + assert!(repeated_sides(&xml).unwrap()); + assert!(repeated_sides(&xml.replace("JobDuplexAllDocumentsContiguously", "DocumentDuplex")).unwrap()); + assert!(!repeated_sides(&ticket_xml(&Job { duplex: Some(Duplex::Off), ..Job::default() }, false)).unwrap()); + assert!(!repeated_sides(&ticket_xml(&Job::default(), false)).unwrap(), "no duplex feature"); + assert!(repeated_sides(&xml.replace("psk:TwoSidedLongEdge", "vendor:Duplex")).is_err(), "an option it can't read"); + let with = |feature: &str| xml.replace("", &format!("{feature}")); + let n_up = |n: u32| { + with(&format!( + r#"{n}"# + )) + }; + assert!(repeated_sides(&n_up(1)).is_ok()); + assert!(repeated_sides(&n_up(2)).is_err()); + let binding = |option: &str| with(&format!(r#""#)); + assert!(repeated_sides(&binding("None")).is_ok()); + assert!(repeated_sides(&binding("Booklet")).is_err()); + } } diff --git a/crates/print/src/windows.rs b/crates/print/src/windows.rs index 765ddb55..441fa60e 100644 --- a/crates/print/src/windows.rs +++ b/crates/print/src/windows.rs @@ -151,12 +151,25 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM let read = &read; // 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 (mut ticket, mut paper) = driver_ticket(&device, job, first, landscape)?; let xml = std::str::from_utf8(ticket.get_xml()).map_err(spool_error)?; - // The capabilities are read only when the ticket lacks a requested value: they tell a - // driver that dropped it from one that never had the feature. Unreadable stays strict. - if ticket::verify_job_options(xml, job, count, ticket::Offered::default()).is_err() { - ticket::verify_job_options(xml, job, count, driver_offers(&device))?; + let mut repeat = None; + // The capabilities are read when the ticket lacks a requested value: they tell a driver + // that dropped it from one that never had the feature. Unreadable stays strict. A driver + // can also keep a collation it doesn't offer, so collated copies always read them. + let checked = ticket::verify_job_options(xml, job, count, ticket::Offered::default()); + if checked.is_err() || ticket::collated_copies(job, count) { + let offered = driver_offers(&device); + if ticket::repeats_collated(job, count, offered) { + let single = Job { copies: 1, ..job.clone() }; + (ticket, paper) = driver_ticket(&device, &single, first, landscape)?; + let xml = std::str::from_utf8(ticket.get_xml()).map_err(spool_error)?; + ticket::verify_job_options(xml, &single, count, offered)?; + // Two-sided as the driver will print it, the queue's default included. + repeat = Some(sheets::Repeat { copies: job.copies.clamp(1, 999), two_sided: ticket::repeated_sides(xml)? }); + } else if checked.is_err() { + ticket::verify_job_options(xml, job, count, offered)?; + } } let (fit, paper) = match job.scaling { Scaling::Exact => (Fit::Exact, first), @@ -166,7 +179,7 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM } }; let edits = sheets::plan(&read.sheets, paper, fit)?; - let updated = if edits.is_empty() { None } else { Some(read.update(&edits)?) }; + let updated = if edits.is_empty() && repeat.is_none() { None } else { Some(read.update(&edits, repeat)?) }; let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(written)?; file.write_all(updated.as_deref().unwrap_or(pdf)).map_err(written)?; drop(file); @@ -184,11 +197,14 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM cause = source.source(); } spool_error(message) - }) + })?; + Ok(repeat) }); drop(dir); - result?; - Ok(format!("spooled {count} sheet(s)")) + Ok(match result? { + Some(repeat) => format!("spooled {count} sheet(s) {} times, collated", repeat.copies), + None => format!("spooled {count} sheet(s)"), + }) } #[cfg(test)] From 865c9af7fdf455cd3892402d18e526a9f019c6bc Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:25:28 -0400 Subject: [PATCH 8/8] print: collated copies that can't be one job print as a job per copy (#308) --- crates/print/README.md | 6 +- crates/print/src/lib.rs | 4 +- crates/print/src/sheets.rs | 63 ++++++++++----------- crates/print/src/tests/pdf_bounds.rs | 70 ++++++++++++++++-------- crates/print/src/tests/windows_native.rs | 24 ++++---- crates/print/src/ticket.rs | 52 ++++++++++-------- crates/print/src/windows.rs | 64 +++++++++++++++------- 7 files changed, 167 insertions(+), 116 deletions(-) diff --git a/crates/print/README.md b/crates/print/README.md index 76694445..22d39e43 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -89,9 +89,9 @@ colour option, uncollated copies on one that can't collate. Two-sided on such a refused. Windows doesn't document collating for a driver that can't collate, so collated copies on one are a single job of one copy that holds the document's pages once per copy; on two-sided paper an odd page count gets a blank page after each copy but the last, so every copy starts on -a sheet of its own. Pages with annotations and encrypted PDFs can't be repeated and are refused, -and so are queues set to print several pages per sheet, to bind, or to a two-sided option outside -the Print Schema's. +a sheet of its own. Where one job would print the copies wrongly or can't hold them (pages with +annotations, an encrypted PDF, a very large job, a queue set to several pages per sheet, binding, +stapling or a two-sided option outside the Print Schema's), each copy is a job of its own. Without an explicit duplex request, the queue default applies. Without a grayscale request the ticket names no colour, so the colour in the user's printing preferences (Properties…, or Windows Settings) applies, black and white included. Single-page or single-copy jobs have no diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index d1702473..ecf97508 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -484,8 +484,8 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result { /// [`submit`] with Windows sheet-processing progress: `(processed, sheets)` before the first /// sheet and after each sheet is successfully added to the native print job. Counts refer to /// source PDF sheets, regardless of copies or duplex, except for collated copies on a driver -/// that can't collate: the job then holds every copy's sheets, and counts them all. CUPS reports -/// no progress. +/// that can't collate: every copy's sheets are sent, in one job or a job per copy, and counted +/// together. CUPS reports no progress. /// /// The callback runs synchronously on the thread calling this function, need not be `Send`, /// and should return promptly. It is never retained. All sheets processed is not terminal diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index ee119602..6e588f61 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -989,7 +989,7 @@ pub(crate) fn read(data: &[u8]) -> Result, Error> { // looked at: resolving a shared Resources dictionary for every page would parse it again. let own_resources = match get(&dict, b"Resources") { None | Some(Obj::Null) => false, - Some(Obj::Ref(num, _)) => reader.entries.contains_key(num), + Some(Obj::Ref(num, _)) => matches!(reader.entries.get(num), Some(Entry::At(_) | Entry::In(..))), Some(_) => true, }; let resources = if own_resources { None } else { parent_resources }; @@ -1102,13 +1102,6 @@ pub(crate) struct Repeat { pub(crate) two_sided: bool, } -/// Collated copies that can't be made by [`Repeat`], and why. -pub(crate) fn not_repeated(why: &str) -> Error { - Error::Spool(format!( - "the printer's driver can't collate copies, and they can't be repeated in one job: {why}; nothing was sent (turn collation off, or print one copy at a time)" - )) -} - fn has_annotations(dict: &Dict) -> bool { get(dict, b"Annots").is_some_and(|a| !matches!(a, Obj::Null) && !matches!(a, Obj::Array(items) if items.is_empty())) } @@ -1141,11 +1134,24 @@ impl Sheets<'_> { /// reframed page wraps its own in, and a cross-reference section of the same kind as the /// newest one, chained to it with `/Prev`. /// - /// With `repeat`, the edited pages are not written in place: the update adds a page tree of - /// its own, every copy's pages as new page objects that share the original content and - /// resources, and a new version of the catalog pointing to it. Each new page carries the - /// boxes, rotation and resources it inherited, since its parent is the new tree. - pub(crate) fn update(&self, edits: &[Edit], repeat: Option) -> Result, Error> { + pub(crate) fn update(&self, edits: &[Edit]) -> Result, Error> { + // Only a repeat can be turned down, so there is always a PDF. + Ok(self.write(edits, None)?.unwrap_or_else(|| self.data.to_vec())) + } + + /// [`update`](Self::update) with every copy of `repeat` in the one PDF. The edited pages + /// are not written in place: the update adds a page tree of its own, every copy's pages as + /// new page objects that share the original content and resources, and a new version of + /// the catalog pointing to it. Each new page carries the boxes, rotation and resources it + /// inherited, since its parent is the new tree. `None` when the copies can't be one PDF: + /// the PDF is encrypted (strings are encrypted with keys made from their object's number), + /// a page has annotations (an annotation belongs to one page; whether a renderer draws it on + /// others is unknown), or the job would be too large. + pub(crate) fn repeated(&self, edits: &[Edit], repeat: Repeat) -> Result>, Error> { + self.write(edits, Some(repeat)) + } + + fn write(&self, edits: &[Edit], repeat: Option) -> Result>, Error> { let refuse = |why: &str| Error::Spool(format!("the print-ready PDF can't be fitted to the paper: {why}")); let mut out = self.data.to_vec(); if !out.ends_with(b"\n") { @@ -1220,27 +1226,18 @@ impl Sheets<'_> { } } if let Some(Repeat { copies, two_sided }) = repeat { - let refuse = not_repeated; - // Strings are encrypted with keys made from their object's number; a page copied - // to a new number would lose them. - if get(&self.trailer, b"Encrypt").is_some() { - return Err(refuse("the PDF is encrypted")); - } - // An annotation belongs to one page; whether a renderer draws it on others is unknown. - if self.sheets.iter().any(|s| s.annotated) { - return Err(refuse("flatten page annotations first")); + let refuse = |why: &str| Error::Spool(format!("the copies can't be written: {why}")); + if get(&self.trailer, b"Encrypt").is_some() || self.sheets.iter().any(|s| s.annotated) { + return Ok(None); } let Some(&Obj::Ref(root, root_generation)) = get(&self.trailer, b"Root") else { - return Err(refuse("the document catalog is not an object of its own")); + return Ok(None); }; let blank = two_sided && self.sheets.len() % 2 == 1; let per_copy = self.sheets.len() + usize::from(blank); - let count = usize::try_from(copies) - .ok() - .and_then(|c| per_copy.checked_mul(c)) - .map(|n| n - usize::from(blank)) - .filter(|&n| n <= MAX_PAGES) - .ok_or_else(|| refuse("too many pages"))?; + let count = + usize::try_from(copies).ok().and_then(|c| per_copy.checked_mul(c)).map(|n| n - usize::from(blank)).filter(|&n| n <= MAX_PAGES); + let Some(count) = count else { return Ok(None) }; let tree = next; next = next.checked_add(1).ok_or_else(|| refuse("too many objects"))?; let parent = (b"Parent".to_vec(), Obj::Ref(tree, 0)); @@ -1281,7 +1278,7 @@ impl Sheets<'_> { out.extend_from_slice(b"\nendobj\n"); once += out.len() - at; if !within(once, each) { - return Err(refuse("the pages are too large to repeat")); + return Ok(None); } shared.insert(Arc::as_ptr(resources), Obj::Ref(num, 0)); Obj::Ref(num, 0) @@ -1296,7 +1293,7 @@ impl Sheets<'_> { write_dict(&mut body, &dict); each += body.len() + 64; if !within(once, each) { - return Err(refuse("the pages are too large to repeat")); + return Ok(None); } bodies.push(body); last = dict; @@ -1338,7 +1335,7 @@ impl Sheets<'_> { out.extend_from_slice(b"\nendobj\n"); } if written.is_empty() { - return Ok(self.data.to_vec()); + return Ok(Some(self.data.to_vec())); } written.sort_by_key(|(num, _, _)| *num); let mut trailer: Dict = Vec::new(); @@ -1385,7 +1382,7 @@ impl Sheets<'_> { write_dict(&mut out, &trailer); out.extend_from_slice(format!("\nstartxref\n{at}\n%%EOF\n").as_bytes()); } - Ok(out) + Ok(Some(out)) } } diff --git a/crates/print/src/tests/pdf_bounds.rs b/crates/print/src/tests/pdf_bounds.rs index b78a006d..e33922bf 100644 --- a/crates/print/src/tests/pdf_bounds.rs +++ b/crates/print/src/tests/pdf_bounds.rs @@ -65,7 +65,7 @@ fn reframe_preserves_single_direct_and_indirect_content_streams() { for (name, contents) in [("single", "3 0 R"), ("direct-array", "[3 0 R]"), ("indirect-array", "5 0 R")] { let original = fixture(contents, compressed, ""); let sheets = read(&original).unwrap(); - let updated = sheets.update(&plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 18.0 }).unwrap(), None).unwrap(); + let updated = sheets.update(&plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 18.0 }).unwrap()).unwrap(); let sheets = read(&updated).unwrap(); let contents = get(&sheets.sheets[0].dict, b"Contents").unwrap().array().unwrap(); assert_eq!(contents.len(), 3); @@ -171,7 +171,7 @@ fn reframe_keeps_array_order_and_treats_null_as_an_empty_page() { for (contents, expected) in [("[3 0 R 3 0 R]", 4), ("null", 2)] { let original = fixture(contents, false, ""); let sheets = read(&original).unwrap(); - let updated = sheets.update(&plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 0.0 }).unwrap(), None).unwrap(); + let updated = sheets.update(&plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 0.0 }).unwrap()).unwrap(); let sheets = read(&updated).unwrap(); let contents = get(&sheets.sheets[0].dict, b"Contents").unwrap().array().unwrap(); assert_eq!(contents.len(), expected); @@ -304,6 +304,10 @@ fn classic(objects: &[String]) -> Vec { /// Three Letter pages that inherit their MediaBox and Resources; the third is turned by its own /// `/Rotate 90`, and carries `extra` in its dictionary. fn three_pages(extra: &str) -> Vec { + classic(&three_page_objects(extra)) +} + +fn three_page_objects(extra: &str) -> Vec { let mut objects = vec![ "<< /Type /Catalog /Pages 2 0 R /PageMode /UseNone >>".to_string(), "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 612 792] /Resources << /ProcSet [/PDF] >> >>".to_string(), @@ -312,7 +316,12 @@ fn three_pages(extra: &str) -> Vec { format!("<< /Type /Page /Parent 2 0 R /Contents 8 0 R /Rotate 90 {extra} >>"), ]; objects.extend((0..3).map(|_| "<< /Length 0 >>\nstream\n\nendstream".to_string())); - classic(&objects) + objects +} + +/// `pdf` with `copies` collated copies in one job, or `None` when they can't be. +fn repeat(pdf: &[u8], copies: u32, two_sided: bool) -> Option> { + read(pdf).unwrap().repeated(&[], Repeat { copies, two_sided }).unwrap() } #[test] @@ -326,7 +335,7 @@ fn collated_copies_repeat_the_pages_with_a_blank_back_between_odd_copies() { (true, vec![content(6), content(7), content(8), None, content(6), content(7), content(8)]), (false, vec![content(6), content(7), content(8), content(6), content(7), content(8)]), ] { - let updated = sheets.update(&edits, Some(Repeat { copies: 2, two_sided })).unwrap(); + let updated = sheets.repeated(&edits, Repeat { copies: 2, two_sided }).unwrap().unwrap(); let repeated = read(&updated).unwrap(); let contents: Vec> = repeated.sheets.iter().map(|s| get(&s.dict, b"Contents").cloned()).collect(); assert_eq!(contents, expected); @@ -341,6 +350,10 @@ fn collated_copies_repeat_the_pages_with_a_blank_back_between_odd_copies() { let resources: Vec<_> = repeated.sheets.iter().filter(|s| get(&s.dict, b"Contents").is_some()).map(|s| get(&s.dict, b"Resources")).collect(); assert!(matches!(resources[0], Some(Obj::Ref(..))) && resources.iter().all(|r| *r == resources[0]), "{resources:?}"); } + // Without the edits the last page stays turned, and so does the blank back after it. + let bytes = repeat(&pdf, 2, true).unwrap(); + let repeated = read(&bytes).unwrap(); + assert_eq!((repeated.sheets[3].size(), repeated.sheets[3].rotate), (repeated.sheets[2].size(), 90)); // An even page count needs no blank; one copy is the document as it is. let even = classic(&[ "<< /Type /Catalog /Pages 2 0 R >>".into(), @@ -348,32 +361,43 @@ fn collated_copies_repeat_the_pages_with_a_blank_back_between_odd_copies() { "<< /Type /Page /Parent 2 0 R >>".into(), "<< /Type /Page /Parent 2 0 R >>".into(), ]); - let repeated = read(&even).unwrap().update(&[], Some(Repeat { copies: 3, two_sided: true })).unwrap(); - assert_eq!(read(&repeated).unwrap().sheets.len(), 6); - assert_eq!(sheets.update(&[], Some(Repeat { copies: 1, two_sided: true })).unwrap(), pdf); + assert_eq!(read(&repeat(&even, 3, true).unwrap()).unwrap().sheets.len(), 6); + assert_eq!(repeat(&pdf, 1, true).unwrap(), pdf); + // A cross-reference stream, with the catalog and the page in an object stream. + for compressed in [false, true] { + let bytes = repeat(&fixture("3 0 R", compressed, ""), 3, false).unwrap(); + let repeated = read(&bytes).unwrap(); + assert_eq!(repeated.sheets.len(), 3); + assert_eq!(repeated.xref_stream, compressed); + } } #[test] -fn collated_copies_that_cannot_be_repeated_are_refused() { - let repeat = Some(Repeat { copies: 2, two_sided: false }); - let annotated = three_pages("/Annots [6 0 R]"); - let refused = read(&annotated).unwrap().update(&[], repeat).unwrap_err(); - assert!(refused.to_string().contains("flatten page annotations"), "{refused}"); +fn collated_copies_that_cannot_be_one_job_are_left_to_separate_jobs() { + assert!(repeat(&three_pages("/Annots [6 0 R]"), 2, false).is_none(), "annotations"); let pdf = three_pages(""); - let sheets = read(&pdf).unwrap(); - let refused = sheets.update(&[], Some(Repeat { copies: 50_000, two_sided: false })).unwrap_err(); - assert!(refused.to_string().contains("too many pages"), "{refused}"); + assert!(repeat(&pdf, 50_000, false).is_none(), "too many pages"); let encrypted = Sheets { trailer: vec![(b"Encrypt".to_vec(), Obj::Ref(9, 0))], ..read(&pdf).unwrap() }; - assert!(encrypted.update(&[], repeat).unwrap_err().to_string().contains("encrypted")); + assert!(encrypted.repeated(&[], Repeat { copies: 2, two_sided: false }).unwrap().is_none(), "encrypted"); } #[test] -fn a_null_or_missing_entry_inherits_and_an_empty_annotation_list_is_none() { - let repeat = Some(Repeat { copies: 2, two_sided: false }); - for extra in ["/Resources null", "/Resources 99 0 R", "/Annots null", "/Annots []"] { - let pdf = three_pages(extra); - let updated = read(&pdf).unwrap().update(&[], repeat).unwrap(); - let repeated = read(&updated).unwrap(); +fn a_null_missing_or_free_entry_inherits_and_an_empty_annotation_list_is_none() { + // Object 9 is written, but its cross-reference entry is free. + let mut objects = three_page_objects("/Resources 9 0 R"); + objects.push("<< /ProcSet [/Text] >>".into()); + let mut free = classic(&objects); + let at = free.windows(9).rposition(|w| w == b"00000 n\r\n").unwrap(); + free[at + 6] = b'f'; + for (extra, pdf) in [ + ("/Resources null", three_pages("/Resources null")), + ("/Resources 99 0 R", three_pages("/Resources 99 0 R")), + ("/Resources 9 0 R (free)", free), + ("/Annots null", three_pages("/Annots null")), + ("/Annots []", three_pages("/Annots []")), + ] { + let bytes = repeat(&pdf, 2, false).unwrap(); + let repeated = read(&bytes).unwrap(); let resources: Vec<_> = repeated.sheets.iter().map(|s| get(&s.dict, b"Resources")).collect(); assert!(matches!(resources[0], Some(Obj::Ref(..))) && resources.iter().all(|r| *r == resources[0]), "{extra}: {resources:?}"); } @@ -390,7 +414,7 @@ fn large_inherited_resources_are_written_once_for_every_copy() { ]; objects.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R >>".to_string())); let pdf = classic(&objects); - let updated = read(&pdf).unwrap().update(&[], Some(Repeat { copies: 3, two_sided: false })).unwrap(); + let updated = repeat(&pdf, 3, false).unwrap(); assert!(updated.len() < pdf.len() + (4 << 20), "{} bytes", updated.len()); assert_eq!(read(&updated).unwrap().sheets.len(), 3 * pages); } diff --git a/crates/print/src/tests/windows_native.rs b/crates/print/src/tests/windows_native.rs index 9762366c..f25f0cfb 100644 --- a/crates/print/src/tests/windows_native.rs +++ b/crates/print/src/tests/windows_native.rs @@ -188,7 +188,7 @@ fn mixed_orientations_turn_to_the_first_sheets() { let sheets = read(&pdf).unwrap(); let edits = plan(&sheets.sheets, (612.0, 792.0), Fit::Exact).unwrap(); assert_eq!(edits.len(), 1, "only the landscape sheet changes"); - let updated = sheets.update(&edits, None).unwrap(); + let updated = sheets.update(&edits).unwrap(); assert!(updated.starts_with(&pdf), "an incremental update: the original bytes stay as they were"); assert_eq!( shapes(&updated), @@ -205,13 +205,13 @@ fn mixed_orientations_turn_to_the_first_sheets() { // A landscape first sheet makes it a landscape job: the portrait sheets turn the other way. let pdf = test_pdf(&[LETTER_LANDSCAPE, TestPage { rotate: Some(180), ..LETTER }], false); let sheets = read(&pdf).unwrap(); - let updated = sheets.update(&plan(&sheets.sheets, (792.0, 612.0), Fit::Exact).unwrap(), None).unwrap(); + let updated = sheets.update(&plan(&sheets.sheets, (792.0, 612.0), Fit::Exact).unwrap()).unwrap(); assert_eq!(shapes(&updated), [((792, 612), 0), ((792, 612), 270)], "180 + 90"); // One shape already: nothing to do, and the job is the PDF as it came. let pdf = test_pdf(&[LETTER, LETTER], true); let sheets = read(&pdf).unwrap(); assert!(plan(&sheets.sheets, (612.0, 792.0), Fit::Exact).unwrap().is_empty()); - assert_eq!(sheets.update(&[], None).unwrap(), pdf); + assert_eq!(sheets.update(&[]).unwrap(), pdf); } #[test] @@ -235,7 +235,7 @@ fn shrink_to_fit_reframes_sheets_on_the_paper() { let pdf = test_pdf(&[TABLOID], true); let sheets = read(&pdf).unwrap(); let edits = plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 18.0 }).unwrap(); - let updated = sheets.update(&edits, None).unwrap(); + let updated = sheets.update(&edits).unwrap(); assert_eq!(shapes(&updated), [((612, 792), 0)]); assert_eq!(frame_of(&updated, pdf.len()), "q 0.6176 0 0 0.6176 61.4118 18 cm 0 0 792 1224 re W n"); let tail = String::from_utf8_lossy(&updated[pdf.len()..]).into_owned(); @@ -252,7 +252,7 @@ fn shrink_to_fit_reframes_sheets_on_the_paper() { assert_eq!(edits[0].frame.as_ref().unwrap().matrix, [1.0, 0.0, 0.0, 1.0, 126.0, 144.0], "never enlarged"); assert_eq!(edits[1].frame.as_ref().unwrap().matrix, [1.0, 0.0, 0.0, 1.0, 56.0, 96.0]); assert_eq!(edits[1].frame.as_ref().unwrap().clip, [100.0, 100.0, 300.0, 400.0]); - assert_eq!(shapes(&sheets.update(&edits, None).unwrap()), [((612, 792), 0), ((612, 792), 0)]); + assert_eq!(shapes(&sheets.update(&edits).unwrap()), [((612, 792), 0), ((612, 792), 0)]); // The paper's own size: shrunk into the printable area when the printer has margins, as // CUPS' auto-fit does, and left alone when it prints to the edge. @@ -268,7 +268,7 @@ fn shrink_to_fit_reframes_sheets_on_the_paper() { let edits = plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 0.0 }).unwrap(); assert_eq!(edits.iter().map(|e| (e.sheet, e.rotate)).collect::>(), [(1, 270), (2, 0)]); assert_eq!(edits[0].frame.as_ref().unwrap().media, (792.0, 612.0), "the paper in the page's unrotated space"); - assert_eq!(shapes(&sheets.update(&edits, None).unwrap()), [((612, 792), 0), ((612, 792), 270), ((612, 792), 0)]); + assert_eq!(shapes(&sheets.update(&edits).unwrap()), [((612, 792), 0), ((612, 792), 270), ((612, 792), 0)]); } #[test] @@ -278,9 +278,9 @@ fn shrink_to_fit_refuses_to_drop_annotation_appearances() { 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, None), Err(Error::Spool(ref m)) if m.contains("flatten page annotations"))); + 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, None).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"); } @@ -288,7 +288,7 @@ fn shrink_to_fit_refuses_to_drop_annotation_appearances() { 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, None).is_ok()); + assert!(sheets.update(&fit).is_ok()); } } @@ -315,11 +315,11 @@ fn an_encrypted_file_is_turned_but_not_reframed() { let encrypted = replaced(&pdf, "/Root 1 0 R >>", "/Root 1 0 R /Encrypt << /Filter /Standard >> >>"); let sheets = crate::sheets::read(&encrypted).unwrap(); let turned = crate::sheets::plan(&sheets.sheets, (612.0, 792.0), crate::sheets::Fit::Exact).unwrap(); - let updated = sheets.update(&turned, None).unwrap(); + let updated = sheets.update(&turned).unwrap(); assert_eq!(shapes(&updated), [((612, 792), 0), ((612, 792), 270)]); assert!(String::from_utf8_lossy(&updated[encrypted.len()..]).contains("/Encrypt"), "the new trailer keeps /Encrypt"); let shrunk = crate::sheets::plan(&sheets.sheets, (612.0, 792.0), crate::sheets::Fit::Shrink { margin: 18.0 }).unwrap(); - assert!(matches!(sheets.update(&shrunk, None), Err(Error::Spool(ref m)) if m.contains("encrypted"))); + assert!(matches!(sheets.update(&shrunk), Err(Error::Spool(ref m)) if m.contains("encrypted"))); } #[test] @@ -348,7 +348,7 @@ fn unreadable_sheets_are_errors_never_panics() { if let Ok(sheets) = crate::sheets::read(&v) && let Ok(edits) = crate::sheets::plan(&sheets.sheets, (612.0, 792.0), crate::sheets::Fit::Shrink { margin: 18.0 }) { - let _ = sheets.update(&edits, None); + let _ = sheets.update(&edits); } } } diff --git a/crates/print/src/ticket.rs b/crates/print/src/ticket.rs index cd6913cd..ade33dc8 100644 --- a/crates/print/src/ticket.rs +++ b/crates/print/src/ticket.rs @@ -217,11 +217,13 @@ pub(crate) fn repeats_collated(job: &Job, pages: usize, offered: Offered) -> boo collated_copies(job, pages) && offered.collate == Some(false) } -/// Whether the validated one-copy ticket of a repeated job ([`crate::sheets::Repeat`]) prints on -/// both sides. Refused when the copies would not each start on a sheet of their own: a duplex -/// option other than the schema's, several pages per sheet, or binding (booklets included). -pub(crate) fn repeated_sides(xml: &str) -> Result { - let document = roxmltree::Document::parse(xml).map_err(spool_error)?; +/// Whether a repeated job's validated one-copy ticket ([`crate::sheets::Repeat`]) prints on +/// both sides; `None` when the copies can't share one job and are printed as a job each: a +/// duplex option other than the schema's (the blank backs would be guessed), several pages per +/// sheet, binding (booklets and offset stacking included) or stapling, which would treat every +/// copy as one document. +pub(crate) fn one_job_sides(xml: &str) -> Option { + let document = roxmltree::Document::parse(xml).ok()?; let features = |names: &'static [&'static str]| { document.root_element().children().filter(move |n| n.has_tag_name((NS_PSF, "Feature")) && names.iter().any(|name| named(*n, name))) }; @@ -233,16 +235,18 @@ pub(crate) fn repeated_sides(xml: &str) -> Result { match option(feature) { Some(o) if named(o, "OneSided") => {} Some(o) if named(o, "TwoSidedLongEdge") || named(o, "TwoSidedShortEdge") => two_sided = true, - _ => return Err(crate::sheets::not_repeated("the queue's two-sided setting is not one of the standard ones")), + _ => return None, } } if features(&["JobNUpAllDocumentsContiguously", "DocumentNUp"]).any(|f| integer(f, "ScoredProperty", "PagesPerSheet").is_some_and(|n| n > 1)) { - return Err(crate::sheets::not_repeated("the queue prints several pages per sheet")); + return None; } - if features(&["JobBindAllDocuments", "DocumentBinding"]).any(|f| !option(f).is_some_and(|o| named(o, "None"))) { - return Err(crate::sheets::not_repeated("the queue binds the pages")); + if features(&["JobBindAllDocuments", "DocumentBinding", "JobStapleAllDocuments", "DocumentStaple"]) + .any(|f| !option(f).is_some_and(|o| named(o, "None"))) + { + return None; } - Ok(two_sided) + Some(two_sided) } /// Confirm explicit job options survived driver validation. Collation only changes output @@ -256,8 +260,8 @@ pub(crate) fn repeated_sides(xml: &str) -> Result { /// driver is refused, and so are collated copies on a driver that can't collate: Windows doesn't /// document collating for such a driver (the standard XPS filters make the copies, and leave /// collated copies to the device only when its GPD or PPD has a Collate command). The job then -/// repeats the document instead ([`repeats_collated`]) and asks for one copy. An unknown offer -/// (`None`) is checked strictly. +/// asks for one copy and repeats the document instead ([`repeats_collated`]), in one job or a +/// job per copy ([`one_job_sides`]). An unknown offer (`None`) is checked strictly. pub(crate) fn verify_job_options(xml: &str, job: &Job, pages: usize, offered: Offered) -> Result<(), Error> { let document = roxmltree::Document::parse(xml).map_err(spool_error)?; let root = document.root_element(); @@ -629,23 +633,25 @@ mod tests { } #[test] - fn a_repeated_job_reads_its_sides_and_refuses_layouts_that_mix_copies() { + fn a_repeated_job_reads_its_sides_and_leaves_layouts_that_mix_copies_to_separate_jobs() { let xml = ticket_xml(&Job { duplex: Some(Duplex::LongEdge), ..Job::default() }, false); - assert!(repeated_sides(&xml).unwrap()); - assert!(repeated_sides(&xml.replace("JobDuplexAllDocumentsContiguously", "DocumentDuplex")).unwrap()); - assert!(!repeated_sides(&ticket_xml(&Job { duplex: Some(Duplex::Off), ..Job::default() }, false)).unwrap()); - assert!(!repeated_sides(&ticket_xml(&Job::default(), false)).unwrap(), "no duplex feature"); - assert!(repeated_sides(&xml.replace("psk:TwoSidedLongEdge", "vendor:Duplex")).is_err(), "an option it can't read"); + assert_eq!(one_job_sides(&xml), Some(true)); + assert_eq!(one_job_sides(&xml.replace("JobDuplexAllDocumentsContiguously", "DocumentDuplex")), Some(true)); + assert_eq!(one_job_sides(&ticket_xml(&Job { duplex: Some(Duplex::Off), ..Job::default() }, false)), Some(false)); + assert_eq!(one_job_sides(&ticket_xml(&Job::default(), false)), Some(false), "no duplex feature"); + assert_eq!(one_job_sides(&xml.replace("psk:TwoSidedLongEdge", "vendor:Duplex")), None, "an option it can't read"); let with = |feature: &str| xml.replace("", &format!("{feature}")); let n_up = |n: u32| { with(&format!( r#"{n}"# )) }; - assert!(repeated_sides(&n_up(1)).is_ok()); - assert!(repeated_sides(&n_up(2)).is_err()); - let binding = |option: &str| with(&format!(r#""#)); - assert!(repeated_sides(&binding("None")).is_ok()); - assert!(repeated_sides(&binding("Booklet")).is_err()); + assert_eq!(one_job_sides(&n_up(1)), Some(true)); + assert_eq!(one_job_sides(&n_up(2)), None); + for feature in ["JobBindAllDocuments", "JobStapleAllDocuments", "DocumentStaple"] { + let finishing = |option: &str| with(&format!(r#""#)); + assert_eq!(one_job_sides(&finishing("None")), Some(true), "{feature}"); + assert_eq!(one_job_sides(&finishing("StapleTopLeft")), None, "{feature}"); + } } } diff --git a/crates/print/src/windows.rs b/crates/print/src/windows.rs index 441fa60e..b99d1cf0 100644 --- a/crates/print/src/windows.rs +++ b/crates/print/src/windows.rs @@ -154,6 +154,8 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM let (mut ticket, mut paper) = driver_ticket(&device, job, first, landscape)?; let xml = std::str::from_utf8(ticket.get_xml()).map_err(spool_error)?; let mut repeat = None; + // Jobs of one copy each, for collated copies that can't be one job. + let mut jobs = 1; // The capabilities are read when the ticket lacks a requested value: they tell a driver // that dropped it from one that never had the feature. Unreadable stays strict. A driver // can also keep a collation it doesn't offer, so collated copies always read them. @@ -166,7 +168,11 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM let xml = std::str::from_utf8(ticket.get_xml()).map_err(spool_error)?; ticket::verify_job_options(xml, &single, count, offered)?; // Two-sided as the driver will print it, the queue's default included. - repeat = Some(sheets::Repeat { copies: job.copies.clamp(1, 999), two_sided: ticket::repeated_sides(xml)? }); + let copies = job.copies.clamp(1, 999); + match ticket::one_job_sides(xml) { + Some(two_sided) => repeat = Some(sheets::Repeat { copies, two_sided }), + None => jobs = copies, + } } else if checked.is_err() { ticket::verify_job_options(xml, job, count, offered)?; } @@ -179,31 +185,49 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM } }; let edits = sheets::plan(&read.sheets, paper, fit)?; - let updated = if edits.is_empty() && repeat.is_none() { None } else { Some(read.update(&edits, repeat)?) }; + let mut updated = None; + if let Some(r) = repeat { + updated = read.repeated(&edits, r)?; + if updated.is_none() { + (repeat, jobs) = (None, r.copies); + } + } + if updated.is_none() && !edits.is_empty() { + updated = Some(read.update(&edits)?); + } let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(written)?; file.write_all(updated.as_deref().unwrap_or(pdf)).map_err(written)?; drop(file); - WinPdfPrinter::new(device) - .print_with_progress(&path, ticket, |processed, total| { - // Page acceptance is distinct from terminal job success. The callback - // drains on the submitter's thread, so it can retain non-Send UI state. - let _ = sender.send((processed as usize, total as usize)); - }) - .map_err(|e| { - let mut message = e.to_string(); - let mut cause = std::error::Error::source(&e); - while let Some(source) = cause { - message.push_str(&format!(": {source}")); - cause = source.source(); - } - spool_error(message) - })?; - Ok(repeat) + let printer = WinPdfPrinter::new(device); + let jobs = jobs as usize; + for copy in 0..jobs { + printer + .print_with_progress(&path, ticket.clone(), |processed, total| { + // Page acceptance is distinct from terminal job success. The callback + // drains on the submitter's thread, so it can retain non-Send UI state. + let (processed, total) = (processed as usize, total as usize); + let _ = sender.send((copy * total + processed, jobs * total)); + }) + .map_err(|e| { + let mut message = e.to_string(); + let mut cause = std::error::Error::source(&e); + while let Some(source) = cause { + message.push_str(&format!(": {source}")); + cause = source.source(); + } + if copy > 0 { + message = format!("{copy} of {jobs} copies were sent, each as a job of its own, and the next was not: {message}"); + } + spool_error(message) + })?; + } + Ok((repeat, jobs)) }); drop(dir); Ok(match result? { - Some(repeat) => format!("spooled {count} sheet(s) {} times, collated", repeat.copies), - None => format!("spooled {count} sheet(s)"), + (Some(repeat), _) => format!("spooled {count} sheet(s) {} times, collated", repeat.copies), + (None, jobs) if jobs > 1 => format!("spooled {count} sheet(s) as {jobs} jobs, one per collated copy"), + _ => format!("spooled {count} sheet(s)"), }) }