Merge pull request #6100 from Combiler01/fix/paper-size-driver-5861

fix(print): auto-detect printer paper size and add page setup sync (fixes #5861)
This commit is contained in:
FredApps authored and GitHub committed 2026-11-10 18:18:09 +01:00
commit 655aa92b5e
23 files changed
+1625 -111

No files matched your search

Generated
+23 -1
View File
@@ -1250,7 +1250,7 @@ dependencies = [
"miniz_oxide 0.9.1",
"roxmltree 0.21.2",
"tempfile",
"winprint",
"winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=3c978dfcf3d460577fabd87aeea7a84ffdcfb396)",
"winsafe",
]
@@ -4665,6 +4665,7 @@ dependencies = [
"pdfcraft-render",
"skrifa 0.44.0",
"thiserror 2.0.21",
"winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49)",
]
[[package]]
@@ -8335,6 +8336,27 @@ dependencies = [
"xml",
]
[[package]]
name = "winprint"
version = "0.2.1"
source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49"
dependencies = [
"flate2",
"fmt-derive",
"reqwest",
"scopeguard",
"sigstore-trust-root",
"sigstore-types",
"sigstore-verify",
"strum",
"tar",
"thiserror 2.0.21",
"windows",
"windows-core",
"windows-implement",
"xml",
]
[[package]]
name = "winreg"
version = "0.55.0"
+1
View File
@@ -731,6 +731,7 @@ impl Automation {
"doc_hidden_info" => self.doc_hidden_info(&a)?,
"printers" => self.printers()?,
"printer_options" => self.printer_options(&a)?,
"printer_papers" => self.printer_papers(&a)?,
"link_list" => self.link_list(&a)?,
"link_open" => self.link_open(&a)?,
"link_add" => self.link_add(&a)?,
+123 -45
View File
@@ -46,45 +46,78 @@ fn bad(m: impl Into<String>) -> ToolError {
ToolError::InvalidArgs(m.into())
}
/// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS`
/// index and size (or a size such as "8.5x14in", whose index is past the list); US Letter when
/// none is given.
fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> {
// A size of its own: "8.5x14in", "210x297mm", "10x15cm", "612x792pt". It has no place in
// `PAPERS` (index `PAPERS.len()`), so no `media` option is sent for it.
if let Some(size) = name.and_then(paper_size) {
return Ok((PAPERS.len(), size));
}
let mut papers = PAPERS.iter().enumerate();
let found = match name {
None => papers.next(),
Some(p) => papers.find(|(_, (n, _))| {
n.eq_ignore_ascii_case(p)
|| n.replace("US ", "").eq_ignore_ascii_case(p)
|| n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p))
}),
/// Parse explicit custom dimensions like "300x500", "300 x 500", "300x500 pt", "300pt x 500pt", "Custom.300x500".
fn parse_custom_dimensions(s: &str) -> Option<(f64, f64)> {
let s = s.trim().trim_start_matches("Custom.").trim_start_matches("custom.").trim();
let (w_str, h_str) = s.split_once(['x', 'X', '*'])?;
let parse_dim = |d: &str| {
let d = d.trim().trim_end_matches("points").trim_end_matches("pt").trim();
d.parse::<f64>().ok()
};
found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| {
bad(format!(
"unknown paper {:?}; choose one of {}, or a size such as 8.5x14in or 210x297mm (1 to 200 inches a side)",
name.unwrap_or_default(),
let w = parse_dim(w_str)?;
let h = parse_dim(h_str)?;
if w.is_finite() && h.is_finite() && w >= 72.0 && h >= 72.0 && w <= 14400.0 && h <= 14400.0 { Some((w, h)) } else { None }
}
/// `doc_print`'s `paper`, by its name, short form, driver-detected paper name, or custom dimensions.
/// Returns `((width, height), media_name)`.
fn resolve_paper(name: Option<&str>, printer: Option<&str>) -> Result<((f64, f64), Option<String>)> {
if let Some(p) = name {
// 1. Match standard PAPERS presets.
for (index, (n, size)) in PAPERS.iter().enumerate() {
if n.eq_ignore_ascii_case(p)
|| n.replace("US ", "").eq_ignore_ascii_case(p)
|| n.split(" (").next().is_some_and(|label| label.eq_ignore_ascii_case(p))
{
let media = print::cups_media(index).map(String::from);
return Ok((*size, media));
}
}
// 2. Match detected driver papers for the given printer.
if let Some(printer_name) = printer {
let driver_papers = spool::printer_papers(printer_name);
if let Some((name, size)) = driver_papers.iter().find(|(n, _)| n.eq_ignore_ascii_case(p)) {
return Ok((*size, Some(print::paper_media(Some(name), *size))));
}
}
// 3. Match custom dimensions ("<w>x<h>").
if let Some(size) = parse_custom_dimensions(p) {
return Ok((size, Some(print::paper_media(None, size))));
}
// 4. Explicit units request the same stock as point dimensions.
if let Some(size) = paper_size(p) {
return Ok((size, Some(print::paper_media(None, size))));
}
return Err(bad(format!(
"unknown paper {p:?}; choose one of {}, a paper from printer_papers, or a size such as 8.5x14in or 210x297mm (1 to 200 inches a side)",
PAPERS.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ")
))
})
)));
}
// When paper is omitted and a printer is given, use the printer's configured default paper if readable.
if let Some(printer_name) = printer
&& let Some((name, size)) = spool::printer_default_paper(printer_name)
{
return Ok((size, Some(print::paper_media(Some(&name), size))));
}
let (name, size) = PAPERS[0];
let media = print::paper_media(Some(name), size);
Ok((size, Some(media)))
}
/// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on
/// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default
/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752), and tells
/// CUPS which way the laid-out `sheets` run, as the Print dialog does (#2713): an orientation the
/// caller chose in `options` stands. It asks the driver for grayscale only when the pages aren't
/// grey pictures already ([`print::driver_needs_grayscale`]).
/// (as `media` after the driver's `options`) rather than the queue's default paper, which a job
/// naming none got (A4 under Letter sheets on the queue in #3752), and tells CUPS which way the
/// laid-out `sheets` run, as the Print dialog does (#2713): an orientation the caller chose in
/// `options` stands. It asks the driver for grayscale only when the pages aren't grey pictures
/// already ([`print::driver_needs_grayscale`]).
fn spool_job(
a: &Args,
printer: &str,
title: String,
duplex: Option<spool::Duplex>,
paper: usize,
media: Option<&str>,
raster: Option<print::Raster>,
sheets: &[print::Sheet],
) -> Result<spool::Job> {
@@ -110,7 +143,7 @@ fn spool_job(
grey_pages: false,
title,
options: {
let mut options: Vec<(String, String)> = options.into_iter().chain(print::media_option(paper)).collect();
let mut options: Vec<(String, String)> = options.into_iter().chain(media.map(|m| ("media".to_string(), m.to_string()))).collect();
print::add_cups_orientation(&mut options, sheets);
options
},
@@ -185,6 +218,21 @@ impl Automation {
Ok(json!({ "printer": printer, "count": list.len(), "options": list }))
}
/// `printer_papers`: the paper sizes (names and dimensions in points) and default paper
/// reported by the printer driver.
pub(crate) fn printer_papers(&self, a: &Args) -> Result<Value> {
let printer = a.str("printer")?;
let resolved = if printer == "default" {
spool::printers().into_iter().find(|p| p.default).map(|p| p.name).unwrap_or_else(|| printer.to_string())
} else {
printer.to_string()
};
let (papers, default_paper) = spool::printer_paper_info(&resolved);
let list: Vec<Value> = papers.into_iter().map(|(name, (width, height))| json!({ "name": name, "width": width, "height": height })).collect();
let default_val = default_paper.map(|(name, (width, height))| json!({ "name": name, "width": width, "height": height }));
Ok(json!({ "printer": printer, "count": list.len(), "papers": list, "default": default_val }))
}
/// `doc_print`: lay the document out with the Print dialog's options, then save the
/// print-ready PDF to `path` or send it to `printer` with the chosen paper.
pub(crate) fn doc_print(&mut self, a: &Args) -> Result<Value> {
@@ -276,7 +324,18 @@ impl Automation {
};
let content = a.opt_str("comments_forms")?.unwrap_or("document-and-markups");
let content = print::names::parse_content(content).ok_or_else(|| bad(format!("unknown comments_forms {content:?}")))?;
let (paper_index, paper) = paper_named(a.opt_str("paper")?)?;
// Use the same queue for duplex detection, paper discovery and submission, even if the
// system default changes while the document is rendered.
let resolved_printer = printer.map(|p| {
if let Some(asked) = &asked {
asked.name.clone()
} else if p == "default" {
spool::printer_queues().into_iter().find(|pr| pr.default).map(|pr| pr.name).unwrap_or_else(|| p.to_string())
} else {
p.to_string()
}
});
let (paper, media) = resolve_paper(a.opt_str("paper")?, resolved_printer.as_deref())?;
// Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when
// `grayscale` is also asked for.
let raster = if a.opt_bool("as_image")?.unwrap_or(false) {
@@ -311,8 +370,8 @@ impl Automation {
out["path"] = json!(path.to_string_lossy());
}
(None, Some(printer)) => {
let printer = asked.as_ref().map_or(printer, |p| p.name.as_str());
let mut job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, settings.raster, &laid_out)?;
let actual_printer = resolved_printer.as_deref().unwrap_or(printer);
let mut job = spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?;
let bytes = pdfcraft_engine::spooler::spooler_copy(bytes, &mut job);
out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?);
}
@@ -397,7 +456,7 @@ mod tests {
#[test]
fn printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them() {
let sheets = |sizes: &[(f64, f64)]| sizes.iter().map(|&size| print::Sheet { size, ..Default::default() }).collect::<Vec<_>>();
let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, 0, None, s).unwrap().options;
let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, Some("Letter"), None, s).unwrap().options;
let media = ("media".to_string(), "Letter".to_string());
let landscape = sheets(&[(792.0, 612.0), (792.0, 612.0)]);
let turned = if cfg!(windows) { vec![media.clone()] } else { vec![media.clone(), ("orientation-requested".to_string(), "4".to_string())] };
@@ -413,8 +472,8 @@ mod tests {
#[test]
fn printing_to_a_printer_feeds_the_laid_out_paper() {
let job = |args: Value, paper_name: Option<&str>| {
let (index, _) = paper_named(paper_name).unwrap();
spool_job(&Args(&args), "Office", "a.pdf".into(), None, index, None, &[]).unwrap()
let (_, media) = resolve_paper(paper_name, None).unwrap();
spool_job(&Args(&args), "Office", "a.pdf".into(), None, media.as_deref(), None, &[]).unwrap()
};
let media = |m: &str| ("media".to_string(), m.to_string());
// US Letter when no paper is asked for, after the driver's own options.
@@ -423,18 +482,36 @@ mod tests {
assert_eq!((letter.printer.as_deref(), letter.title.as_str()), (Some("Office"), "a.pdf"));
// Every paper, by its full name, goes out as its own media name.
for (i, (name, size)) in PAPERS.iter().enumerate() {
assert_eq!(paper_named(Some(name)).ok(), Some((i, *size)), "{name}");
assert_eq!(resolve_paper(Some(name), None).ok(), Some((*size, Some(print::cups_media(i).unwrap().to_string()))), "{name}");
assert_eq!(job(json!({}), Some(name)).options, print::media_option(i).into_iter().collect::<Vec<_>>(), "{name}");
}
// Short forms find the same paper.
assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]);
assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]);
assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter")));
// A size of its own is laid out as asked and names no media.
assert_eq!(paper_named(Some("8.5x14in")).ok(), Some((PAPERS.len(), (612.0, 1008.0))));
assert!(job(json!({}), Some("8.5x14in")).options.is_empty());
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0, None, &[]).ok().map(|j| j.printer), Some(None));
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0, None, &[]).is_err());
// Non-preset dimensions and driver papers carry their media selection.
assert_eq!(job(json!({}), Some("300x500")).options, [media("Custom.300x500")]);
assert_eq!(job(json!({}), Some("300 x 500 pt")).options, [media("Custom.300x500")]);
assert_eq!(job(json!({}), Some("300pt x 500pt")).options, [media("Custom.300x500")]);
assert_eq!(job(json!({}), Some("300 pt x 500 pt")).options, [media("Custom.300x500")]);
assert_eq!(job(json!({}), Some("Custom.300x500")).options, [media("Custom.300x500")]);
assert_eq!(job(json!({}), Some("Custom.300x500pt")).options, [media("Custom.300x500")]);
assert!(matches!(resolve_paper(Some("Quarto"), None), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter")));
// Every accepted dimension syntax requests the same stock from the queue.
for (input, size) in [
("8.5x14in", (612.0, 1008.0)),
("210x297mm", (210.0 * 72.0 / 25.4, 297.0 * 72.0 / 25.4)),
("10x15cm", (10.0 * 72.0 / 2.54, 15.0 * 72.0 / 2.54)),
("300x500pt", (300.0, 500.0)),
] {
let expected = print::paper_media(None, size);
let (resolved_size, resolved_media) = resolve_paper(Some(input), None).unwrap();
assert!((resolved_size.0 - size.0).abs() < 0.01 && (resolved_size.1 - size.1).abs() < 0.01, "{input}");
assert_eq!(resolved_media.as_deref(), Some(expected.as_str()), "{input}");
assert_eq!(job(json!({}), Some(input)).options, [media(&expected)], "{input}");
}
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, Some("Letter"), None, &[]).ok().map(|j| j.printer), Some(None));
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, Some("Letter"), None, &[]).is_err());
}
/// #5806: grayscale with Print as image sends grey pictures, so the driver isn't asked for
@@ -443,8 +520,9 @@ mod tests {
fn grey_pictures_dont_ask_the_driver_for_grayscale() {
let grey = print::Raster { dpi: 300.0, grayscale: true };
let colour = print::Raster { dpi: 300.0, grayscale: false };
let job =
|args: Value, raster: Option<print::Raster>| spool_job(&Args(&args), "Office", String::new(), None, 0, raster, &[]).unwrap().grayscale;
let job = |args: Value, raster: Option<print::Raster>| {
spool_job(&Args(&args), "Office", String::new(), None, Some("Letter"), raster, &[]).unwrap().grayscale
};
assert!(job(json!({ "grayscale": true }), None), "vector pages: the driver makes them grey");
assert!(!job(json!({ "grayscale": true }), Some(grey)), "grey pictures: nothing for the driver to do");
assert!(job(json!({ "grayscale": true }), Some(colour)), "colour pictures still need the driver");
+8 -1
View File
@@ -754,10 +754,17 @@ pub fn tools() -> Vec<ToolDef> {
t("printer_options", "List printer options", "A printer driver's own job options (CUPS: from its PPD), such as the paper tray, paper type or finishing: key, label, group, default and choices. Pass chosen values to doc_print as options. Empty on Windows, where the driver's preferences window holds them.")
.ro()
.with(schema(json!({ "printer": { "type": "string" } }), &["printer"])),
t(
"printer_papers",
"List printer paper sizes",
"The media sizes (names and dimensions in points) and default paper supported by a printer driver. Pass printer: a name from printers, or \"default\".",
)
.ro()
.with(schema(json!({ "printer": { "type": "string" } }), &["printer"])),
t(
"doc_print",
"Print",
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed, \"(n)\" for the nth page whatever its label, unless a page is labelled exactly \"(n)\", since labels are looked up first; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed, \"(n)\" for the nth page whatever its label, unless a page is labelled exactly \"(n)\", since labels are looked up first; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, US #10 Envelope, a driver paper name from printer_papers, or dimensions \"<w>x<h>\"; when printer is given and paper is omitted, uses the printer's default paper if available; use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
)
.with(schema(
json!({
+19
View File
@@ -6292,6 +6292,25 @@ fn printing_through_tools() {
assert!(matches!(a.call("doc_print", &json!({ "doc": doc, "pages": "99", "path": "x.pdf" })), Err(ToolError::InvalidArgs(_))));
}
#[test]
fn printer_papers_through_tools() {
let dir = workdir("printer-papers");
let mut a = auto(&dir);
let none = ok(&mut a, "printer_papers", json!({ "printer": "No_Such_Queue_PdfCraft" }));
assert_eq!((none["count"].as_u64(), none["papers"].as_array().map(Vec::len)), (Some(0), Some(0)));
assert!(none["default"].is_null());
assert!(matches!(a.call("printer_papers", &json!({})), Err(ToolError::InvalidArgs(_))));
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
let r = ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "paper": "300x500", "path": "custom-paper.pdf" }));
assert_eq!(r["sheets"].as_u64(), Some(1));
let opened = ok(&mut a, "doc_open", json!({ "path": "custom-paper.pdf" }))["doc"].as_u64().unwrap();
let info = ok(&mut a, "doc_info", json!({ "doc": opened }));
let page = &info["pages"][0];
assert!((page["width"].as_f64().unwrap() - 300.0).abs() <= 1.0);
assert!((page["height"].as_f64().unwrap() - 500.0).abs() <= 1.0);
}
#[test]
fn adding_content_through_tools() {
let dir = workdir("content");
+3
View File
@@ -16,6 +16,9 @@ pdfcraft-content = { workspace = true }
pdfcraft-model = { workspace = true }
thiserror = { workspace = true }
[target.'cfg(windows)'.dependencies]
winprint = { version = "0.2.1", git = "https://github.com/storytold/winprint.rs", rev = "5f80b9baf6b58937c401656634e42d33f00ddf49", default-features = false }
[dev-dependencies]
pdfcraft-render = { workspace = true }
# Reads the renamed programs back with a parser that isn't ours.
+2 -1
View File
@@ -39,13 +39,14 @@ use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents,
pub use imposition::{impose, keep_printed, sheet_pages};
pub use settings::{
Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, MAX_GRID, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex,
booklet_job_duplex, cups_media, driver_needs_grayscale, media_option,
booklet_job_duplex, cups_media, custom_media, driver_needs_grayscale, media_option, paper_media, paper_media_option,
};
#[cfg(test)]
use sheets::text_direction;
pub use sheets::{
LABEL_MARGIN, MARGIN, MAX_CREEP, Placement, Sheet, add_cups_orientation, cups_orientation, layout, matching_paper, text_reads_up, texts_read_up,
};
pub use spool::{open_printer_preferences, printer_default_paper, printer_paper_info, printer_papers, show_printer_properties};
/// The names of the Print dialog's choices, shared by the `doc_print` tool and the settings
/// file, so a saved preference reads like a tool argument and there is one vocabulary to keep.
+49
View File
@@ -1,4 +1,5 @@
use super::spool;
use crate::matching_paper;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Orientation {
@@ -301,3 +302,51 @@ pub fn cups_media(index: usize) -> Option<&'static str> {
pub fn media_option(index: usize) -> Option<(String, String)> {
cups_media(index).map(|m| ("media".to_string(), m.to_string()))
}
/// Format custom dimensions in points as a CUPS custom media string (e.g. `Custom.300x500`).
pub fn custom_media(size: (f64, f64)) -> String {
let format_dim = |d: f64| {
if (d.round() - d).abs() < 1e-4 {
format!("{:.0}", d.round())
} else {
let s = format!("{d:.2}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
};
format!("Custom.{}x{}", format_dim(size.0), format_dim(size.1))
}
/// The CUPS media name for a paper by its name and dimensions.
///
/// Matches standard [`PAPERS`] presets by size first, then a clean driver-reported keyword,
/// falling back to custom media dimensions (`Custom.<width>x<height>`).
pub fn paper_media(name: Option<&str>, size: (f64, f64)) -> String {
if let Some(index) = matching_paper(size)
&& let Some(media) = cups_media(index)
{
return media.to_string();
}
if let Some(n) = name {
let is_dim = n.split_once(['x', 'X', '*']).is_some_and(|(w, h)| {
let parse_dim = |d: &str| {
let d = d.trim().trim_start_matches("Custom.").trim_start_matches("custom.").trim_end_matches("points").trim_end_matches("pt").trim();
d.parse::<f64>().is_ok()
};
parse_dim(w) && parse_dim(h)
});
let is_clean = !n.is_empty()
&& n.len() <= 64
&& !n.contains(' ')
&& !is_dim
&& n.chars().all(|c| c.is_ascii_graphic() && !matches!(c, '/' | ':' | '=' | '"' | '\'' | '\\' | '{'));
if is_clean {
return n.to_string();
}
}
custom_media(size)
}
/// The media job option (`("media", <name>)`) for a paper by its optional name and dimensions.
pub fn paper_media_option(name: Option<&str>, size: (f64, f64)) -> (String, String) {
("media".to_string(), paper_media(name, size))
}
+146
View File
@@ -53,6 +53,19 @@ fn unavailable(dll: &str) -> crate::PrintError {
crate::PrintError::Spool(format!("printing needs {dll}, which this Windows installation lacks; use Save as PDF instead"))
}
/// Keeps two threads of this process out of the Windows PrintTicket provider (`PTOpenProvider`
/// and the calls on its handle) at the same time. When two threads make the process's first calls
/// together, winspool can keep a pointer to a device capabilities cache it has freed, and a later
/// call crashes the process. All entry points in this process that query driver capabilities
/// or submit jobs share this process-wide lock.
#[cfg(windows)]
static PRINT_TICKET_PROVIDER: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(windows)]
pub(crate) fn print_ticket_provider() -> std::sync::MutexGuard<'static, ()> {
PRINT_TICKET_PROVIDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The system's printers; none on a Windows installation without the print DLLs.
pub fn printers() -> Vec<Printer> {
if missing_print_dll().is_some() {
@@ -60,6 +73,8 @@ pub fn printers() -> Vec<Printer> {
}
#[cfg(all(windows, test))]
PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1)));
#[cfg(windows)]
let _provider = print_ticket_provider();
craft_print::printers()
}
@@ -83,6 +98,8 @@ pub fn two_sided(printer: &str) -> Option<bool> {
}
#[cfg(all(windows, test))]
PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1)));
#[cfg(windows)]
let _provider = print_ticket_provider();
craft_print::two_sided(printer)
}
@@ -386,11 +403,14 @@ fn spooled(job: &Job) -> Job {
}
}
/// Submit a PDF to the system print spooler for the configured `job`.
pub fn submit(pdf: &[u8], job: &Job) -> Result<String, crate::PrintError> {
if let Some(dll) = missing_print_dll() {
return Err(unavailable(dll));
}
#[cfg(windows)]
let _provider = print_ticket_provider();
#[cfg(windows)]
let resolved = windows_duplex::job(job);
#[cfg(windows)]
let job = resolved.as_ref();
@@ -405,6 +425,8 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz
return Err(unavailable(dll));
}
#[cfg(windows)]
let _provider = print_ticket_provider();
#[cfg(windows)]
let resolved = windows_duplex::job(job);
#[cfg(windows)]
let job = resolved.as_ref();
@@ -426,10 +448,134 @@ pub(crate) fn spooled_pdf(pdf: &[u8], rename: bool) -> std::borrow::Cow<'_, [u8]
}
}
/// Opens the printer driver's native Printing Preferences window (Windows `printui.dll`).
pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> {
craft_print::open_printer_preferences(printer).map_err(|e| crate::PrintError::Spool(e.to_string()))
}
/// A named paper with dimensions in points `(width, height)`.
pub type Paper = (String, (f64, f64));
/// The paper sizes supported by `printer` as reported by the system / printer driver.
/// Empty when the printer reports none or on platforms where driver capabilities are not queried.
#[cfg(windows)]
pub fn printer_papers(printer: &str) -> Vec<Paper> {
// A call into a missing delay-loaded PrintTicket DLL would terminate the process (#2630).
if missing_print_dll().is_some() {
return Vec::new();
}
let _provider = print_ticket_provider();
printer_papers_internal(printer)
}
#[cfg(windows)]
fn printer_papers_internal(printer: &str) -> Vec<Paper> {
#[cfg(test)]
PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1)));
use winprint::ticket::{FeatureOptionPack as _, FeatureOptionPackWithPredefined as _};
let Ok(devices) = winprint::printer::PrinterDevice::all() else { return Vec::new() };
let Some(device) = devices.into_iter().find(|d| d.name() == printer) else { return Vec::new() };
let Ok(capabilities) = winprint::ticket::PrintCapabilities::fetch(&device) else { return Vec::new() };
let mut papers = Vec::new();
for media in capabilities.page_media_sizes() {
let size = media.size();
let w_pts = (size.width_in_micron() as f64) * 72.0 / 25_400.0;
let h_pts = (size.height_in_micron() as f64) * 72.0 / 25_400.0;
if w_pts.is_finite() && h_pts.is_finite() && w_pts >= 72.0 && h_pts >= 72.0 && w_pts <= 14400.0 && h_pts <= 14400.0 {
let name = media.display_name().map(str::to_string).unwrap_or_else(|| {
if let Some(predefined) = media.as_predefined_name() { format!("{predefined:?}") } else { format!("{w_pts:.1} x {h_pts:.1} pt") }
});
let w_pts = (w_pts * 100.0).round() / 100.0;
let h_pts = (h_pts * 100.0).round() / 100.0;
if !papers.iter().any(|(n, (w, h)): &(String, (f64, f64))| n == &name && (*w - w_pts).abs() < 0.1 && (*h - h_pts).abs() < 0.1) {
papers.push((name, (w_pts, h_pts)));
}
}
}
papers
}
/// The paper sizes supported by `printer` (fallback for non-Windows platforms).
#[cfg(not(windows))]
pub fn printer_papers(_printer: &str) -> Vec<Paper> {
Vec::new()
}
/// Match `paper_pts` against detected driver `papers` within a 1.0 pt tolerance.
#[cfg(windows)]
fn resolve_paper_name(papers: &[Paper], paper_pts: (f64, f64)) -> Paper {
let w_pts = (paper_pts.0 * 100.0).round() / 100.0;
let h_pts = (paper_pts.1 * 100.0).round() / 100.0;
let name = papers
.iter()
.find(|(_, (w, h))| ((*w - w_pts).abs() <= 1.0 && (*h - h_pts).abs() <= 1.0) || ((*w - h_pts).abs() <= 1.0 && (*h - w_pts).abs() <= 1.0))
.map(|(n, _)| n.clone())
.or_else(|| crate::matching_paper(paper_pts).and_then(|idx| crate::PAPERS.get(idx).map(|(n, _)| n.to_string())))
.unwrap_or_default();
(name, (w_pts, h_pts))
}
/// The default paper size configured for `printer` in the printer properties / DEVMODE.
/// Returns `Some((name, (width, height)))` if readable.
pub fn printer_default_paper(printer: &str) -> Option<Paper> {
printer_paper_info(printer).1
}
/// Reads the supported paper sizes and the default paper size configured for `printer`.
pub fn printer_paper_info(printer: &str) -> (Vec<Paper>, Option<Paper>) {
if missing_print_dll().is_some() {
return (Vec::new(), None);
}
#[cfg(windows)]
let _provider = print_ticket_provider();
#[cfg(windows)]
let papers = printer_papers_internal(printer);
#[cfg(not(windows))]
let papers = printer_papers(printer);
#[cfg(windows)]
let default_paper = {
let settings = craft_print::rgb::default_settings(printer).ok();
let page = settings.as_ref().and_then(|s| craft_print::rgb::page(printer, Some(s)).ok());
page.map(|p| resolve_paper_name(&papers, p.paper))
};
#[cfg(not(windows))]
let default_paper = {
let _ = &papers;
None
};
(papers, default_paper)
}
/// Shows the printer driver's Page Setup / DocumentProperties dialog (Windows).
///
/// Returns the paper size and orientation selected by the user. Other per-job driver
/// settings (such as tray or quality) are not captured; general driver settings should
/// be configured through [`open_printer_preferences`] (Properties…).
pub fn show_printer_properties(printer: &str, parent: Option<isize>) -> Result<Option<Paper>, crate::PrintError> {
if let Some(dll) = missing_print_dll() {
return Err(unavailable(dll));
}
#[cfg(windows)]
{
let devmode = match craft_print::rgb::settings_dialog(printer, None, parent) {
Ok(Some(dm)) => dm,
Ok(None) => return Ok(None),
Err(_) => {
open_printer_preferences(printer)?;
return Ok(None);
}
};
let page = craft_print::rgb::page(printer, Some(&devmode)).map_err(|e| crate::PrintError::Spool(e.to_string()))?;
let papers = printer_papers(printer);
Ok(Some(resolve_paper_name(&papers, page.paper)))
}
#[cfg(not(windows))]
{
let _ = (printer, parent);
Ok(None)
}
}
/// The PPD language codes (`*cupsLanguages`, the `ll` of `*ll.Translation`) to try for a UI
/// language code, most specific first: `pt-br` → `pt_BR`, `pt`; `zh-hans` → `zh_CN`, `zh`.
// Read only by the CUPS path; the tests exercise it everywhere.
+113
View File
@@ -1159,6 +1159,28 @@ fn every_paper_reaches_the_spooler_by_a_name_of_its_size() {
);
}
#[test]
fn paper_media_and_custom_dimensions() {
assert_eq!(custom_media((300.0, 500.0)), "Custom.300x500");
assert_eq!(custom_media((300.5, 500.25)), "Custom.300.5x500.25");
// Standard presets match by size regardless of name:
assert_eq!(paper_media(None, (612.0, 792.0)), "Letter");
assert_eq!(paper_media(Some("Driver Letter"), (612.0, 792.0)), "Letter");
assert_eq!(paper_media(Some("Shipping Label"), (288.0, 432.0)), "w288h432");
// Clean driver keyword that does not match PAPERS presets:
assert_eq!(paper_media(Some("Roll80mm"), (226.77, 800.0)), "Roll80mm");
assert_eq!(paper_media(Some("Photo5x7"), (360.0, 504.0)), "Photo5x7");
// Driver paper with spaces or raw dimensions falls back to Custom:
assert_eq!(paper_media(Some("Custom 5x7"), (360.0, 504.0)), "Custom.360x504");
assert_eq!(paper_media(Some("300x500"), (300.0, 500.0)), "Custom.300x500");
assert_eq!(paper_media(None, (300.0, 500.0)), "Custom.300x500");
assert_eq!(paper_media_option(Some("300x500"), (300.0, 500.0)), ("media".to_string(), "Custom.300x500".to_string()));
}
/// #4777: Folio (8.5 × 13 in) and F4 (210 × 330 mm) presets are offered, fed by their
/// PWG 5101.1 media names (`na_foolscap_8.5x13in` and `om_folio_210x330mm`) and laid out at their
/// proper dimensions.
@@ -1681,6 +1703,97 @@ fn printing_without_the_print_dlls_is_refused_before_the_spooler() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #2630 and #6100: paper discovery must respect the same delay-load guard as submission.
/// The nonexistent queue makes a missing guard observable without requiring printer hardware.
#[cfg(windows)]
#[test]
fn paper_detection_without_print_dlls_never_reaches_the_driver() {
use crate::spool::{PRINT_DLLS, PRINTER_BACKEND_CALLS, SYSTEM32_FOR_TEST};
let dir = std::env::temp_dir().join(format!("pdfcraft-paper-no-dlls-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for missing in PRINT_DLLS {
SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = Some(dir.clone()));
PRINTER_BACKEND_CALLS.with(|c| c.set(0));
let name = "PdfCraft test: no such printer";
let papers = crate::spool::printer_papers(name);
let info = crate::spool::printer_paper_info(name);
let default = crate::spool::printer_default_paper(name);
let calls = PRINTER_BACKEND_CALLS.with(|c| c.get());
// A broken guard must not open a real dialog while this regression fails.
let properties = (calls == 0).then(|| crate::spool::show_printer_properties(name, None));
SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = None);
assert!(papers.is_empty());
assert_eq!(info, (Vec::new(), None));
assert_eq!(default, None);
assert_eq!(calls, 0, "paper queries must not reach the backend without {missing}");
assert!(matches!(properties, Some(Err(crate::PrintError::Spool(m))) if m.contains(missing)));
std::fs::write(dir.join(missing), b"").unwrap();
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn printer_papers_and_default_paper_api() {
// Nonexistent printer returns empty
assert!(crate::spool::printer_papers("Nonexistent Printer 12345").is_empty());
assert_eq!(crate::spool::printer_default_paper("Nonexistent Printer 12345"), None);
#[cfg(windows)]
{
// On Windows with installed printers, test detection if any installed
for p in crate::spool::printers() {
let papers = crate::spool::printer_papers(&p.name);
if !papers.is_empty() {
// Every paper has a valid positive size and a non-empty name
for (name, (w, h)) in &papers {
assert!(!name.is_empty());
assert!(*w > 0.0 && *h > 0.0);
}
}
}
}
}
/// Overlapping driver discovery, queue queries, and paper detection run concurrently across
/// worker threads without deadlocking or racing the PrintTicket provider.
#[test]
fn overlapping_paper_discovery_two_sided_and_queue_queries() {
let queues = crate::spool::printer_queues();
let name = queues.first().map(|p| p.name.clone()).unwrap_or_else(|| "Microsoft Print to PDF".to_string());
let threads: Vec<_> = (0..8)
.map(|i| {
let printer = name.clone();
std::thread::spawn(move || {
for _ in 0..5 {
match i % 5 {
0 => {
let _ = crate::spool::printer_papers(&printer);
}
1 => {
let _ = crate::spool::printer_default_paper(&printer);
}
2 => {
let _ = crate::spool::two_sided(&printer);
}
3 => {
let _ = crate::spool::printers();
}
_ => {
let _ = crate::spool::printer_paper_info(&printer);
}
}
}
})
})
.collect();
for handle in threads {
handle.join().unwrap();
}
}
#[test]
fn a_page_form_without_annotations_shares_the_pages_indirect_resources() {
let mut doc = fixture(2);
+2
View File
@@ -132,6 +132,8 @@ impl PdfCraftApp {
updates: updates::Updates::default(),
printer_source: None,
options_source: None,
default_paper_source: None,
printer_papers_source: None,
spooler: None,
two_sided_source: None,
print_tasks: print_ui::PrintTasks::default(),
+16 -2
View File
@@ -107,6 +107,16 @@ impl PdfCraftApp {
/// later as a pending open, under the returned token.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn schedule_open(&mut self, request: OpenRequest) -> Result<u64, String> {
self.schedule_open_with_priority(request, false)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn schedule_open_priority(&mut self, request: OpenRequest) -> Result<u64, String> {
self.schedule_open_with_priority(request, true)
}
#[cfg(not(target_arch = "wasm32"))]
fn schedule_open_with_priority(&mut self, request: OpenRequest, priority: bool) -> Result<u64, String> {
if self.open_worker.is_none() {
self.open_worker = Some(
open_worker::OpenWorker::new(
@@ -124,7 +134,11 @@ impl PdfCraftApp {
// A new document opens with the remembered Highlight fields, which its scripts read; the
// session keeps the document in front's.
let context = self.session.open_context().with_highlight(self.view_defaults.highlight_fields);
let receiver = worker.submit(token, context, request.name.clone(), request.path.clone(), request.password, request.input)?;
let receiver = if priority {
worker.submit_priority(token, context, request.name.clone(), request.path.clone(), request.password, request.input)?
} else {
worker.submit(token, context, request.name.clone(), request.path.clone(), request.password, request.input)?
};
self.next_open_token = token;
self.pending_opens.push(PendingOpen {
token,
@@ -660,7 +674,7 @@ impl PdfCraftApp {
};
#[cfg(not(target_arch = "wasm32"))]
if self.ctx.is_some() && !self.run_inline {
let result = self.schedule_open(OpenRequest {
let result = self.schedule_open_priority(OpenRequest {
name: p.name.clone(),
path: p.path.clone(),
stamp: p.stamp,
+30 -3
View File
@@ -37,6 +37,7 @@ fn rgb_triple(v: &serde_json::Value) -> Option<[f64; 3]> {
}
impl PdfCraftApp {
/// Sets the user's interface theme preference (Light, Dark, or System).
pub fn set_theme_preference(&mut self, preference: ThemePreference) {
self.theme_preference = preference;
if let Some(ctx) = self.ctx.clone() {
@@ -191,6 +192,9 @@ impl PdfCraftApp {
// a custom paper is stored by the name it was given.
let d = &self.print_draft;
v["print_paper"] = serde_json::json!((d.paper < d.paper_count()).then(|| d.paper_name()));
let size = self.print_draft.paper_size();
v["print_paper_size"] = serde_json::json!([size.0, size.1]);
v["print_paper_overridden"] = serde_json::json!(self.print_draft.paper_overridden);
// Also set outside json! above, which is at serde_json's macro recursion limit.
v["print"] = print;
// Each printer's paper, sizing and two-sided choice, the one printed to last first (#2001).
@@ -312,8 +316,31 @@ impl PdfCraftApp {
self.print_draft.custom_papers = print_paper::read_custom(&v["print_custom_papers"]);
}
// After the custom papers, so a custom paper chosen last time is found by its name.
if let Some(paper) = v.get("print_paper").and_then(serde_json::Value::as_str).and_then(|name| self.print_draft.paper_index(name)) {
self.print_draft.paper = paper;
if let Some(name) = v.get("print_paper").and_then(serde_json::Value::as_str) {
let saved_size = v.get("print_paper_size").and_then(|s| {
let w = s.get(0)?.as_f64()?;
let h = s.get(1)?.as_f64()?;
if w >= 72.0 && h >= 72.0 && w.is_finite() && h.is_finite() { Some((w, h)) } else { None }
});
let matched_pos = self
.print_draft
.papers
.iter()
.position(|(n, s)| n == name && saved_size.is_none_or(|sz| (s.0 - sz.0).abs() <= 1.0 && (s.1 - sz.1).abs() <= 1.0))
.or_else(|| {
pdfcraft_engine::print::PAPERS.iter().position(|(canonical, s)| {
*canonical == name && saved_size.is_none_or(|sz| (s.0 - sz.0).abs() <= 1.0 && (s.1 - sz.1).abs() <= 1.0)
})
})
.or_else(|| self.print_draft.paper_index(name));
if let Some(paper) = matched_pos {
self.print_draft.paper = paper;
self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(false);
} else if let Some(size) = saved_size {
self.print_draft.papers.push((name.to_string(), size));
self.print_draft.paper = self.print_draft.papers.len() - 1;
self.print_draft.paper_overridden = v.get("print_paper_overridden").and_then(serde_json::Value::as_bool).unwrap_or(false);
}
}
if let Some(setups) = crate::print_ui::setups_from_json(&v["print_printers"]) {
self.print_draft.setups = setups;
@@ -366,6 +393,7 @@ impl PdfCraftApp {
if let Some(on) = v["always_default_view"].as_bool() {
self.always_default_view = on;
}
self.restore_print_prefs(v);
if let Some(on) = v["highlight_fields"].as_bool() {
self.view_defaults.highlight_fields = on;
}
@@ -375,7 +403,6 @@ impl PdfCraftApp {
if let Some(closed) = v["right_panel_closed"].as_bool() {
self.right_panel_closed = closed;
}
self.restore_print_prefs(v);
// Scan & OCR choices (language, engine, strategy, output mode, preprocessing and skip
// flags); unknown or malformed values keep the defaults.
self.ocr_draft.apply_pref(&v["ocr"]);
+6 -2
View File
@@ -60,8 +60,8 @@ mod hotbar;
mod panels;
pub mod panes;
pub use print_ui::{
Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSetup, PrinterSource, Spooler, TwoSidedSource, Which as PrintWhich, system_printers,
system_two_sided,
DefaultPaperSource, Handling as PrintHandling, OptionsSource, PrintDraft, PrinterPapersSource, PrinterSetup, PrinterSource, Spooler,
TwoSidedSource, Which as PrintWhich, system_printers, system_two_sided,
};
pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode};
pub mod i18n;
@@ -435,6 +435,10 @@ pub struct PdfCraftApp {
pub printer_source: Option<PrinterSource>,
/// Where Properties… reads a printer's driver options: the spooler's unless a test stands in.
pub options_source: Option<OptionsSource>,
/// Where a printer's configured default paper comes from: DEVMODE / driver settings unless a test stands in.
pub default_paper_source: Option<DefaultPaperSource>,
/// Where a printer's supported paper sizes come from: driver capabilities unless a test stands in.
pub printer_papers_source: Option<PrinterPapersSource>,
/// What print jobs go to: the system spooler unless a test stands in ([`Spooler`]).
pub spooler: Option<Spooler>,
/// Reads the picked printer's two-sided support in the background (the desktop app sets it;
+52 -14
View File
@@ -83,29 +83,67 @@ impl OpenWorker {
password: Option<zeroize::Zeroizing<String>>,
input: Input,
) -> Result<Receiver<ResultMessage>, String> {
let (job, receiver) = self.build_job(token, context, name, path, password, input);
self.enqueue(job, false)?;
Ok(receiver)
}
pub fn submit_priority(
&self,
token: u64,
context: OpenContext,
name: String,
path: Option<String>,
password: Option<zeroize::Zeroizing<String>>,
input: Input,
) -> Result<Receiver<ResultMessage>, String> {
let (job, receiver) = self.build_job(token, context, name, path, password, input);
self.enqueue(job, true)?;
Ok(receiver)
}
fn build_job(
&self,
token: u64,
context: OpenContext,
name: String,
path: Option<String>,
password: Option<zeroize::Zeroizing<String>>,
input: Input,
) -> (Job, Receiver<ResultMessage>) {
let (reply, receiver) = mpsc::channel();
let job = Job {
token,
context,
name,
path,
password,
input,
reply,
#[cfg(test)]
gate: self.gate.clone(),
};
(
Job {
token,
context,
name,
path,
password,
input,
reply,
#[cfg(test)]
gate: self.gate.clone(),
},
receiver,
)
}
fn enqueue(&self, job: Job, priority: bool) -> Result<(), String> {
let (lock, ready) = &*self.queue;
let mut queue = lock.lock().unwrap_or_else(PoisonError::into_inner);
if queue.shutdown {
return Err("the document-open worker has stopped".into());
}
if queue.jobs.len() >= MAX_QUEUED_OPENS {
if !priority && queue.jobs.len() >= MAX_QUEUED_OPENS {
return Err("too many documents are waiting to open".into());
}
queue.jobs.push_back(job);
if priority {
queue.jobs.push_front(job);
} else {
queue.jobs.push_back(job);
}
ready.notify_one();
Ok(receiver)
Ok(())
}
}
@@ -922,6 +922,55 @@ fn a_repeated_waiting_open_keeps_the_tool_it_brings() {
assert_eq!(app.deferred_opens.front().and_then(|d| d.then), Some("export.text"));
}
/// A password retry submits with priority and succeeds even when the open worker queue is full
/// of ordinary background documents, placing the retry at the head of the worker queue.
#[test]
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
fn password_retry_submits_with_priority_when_worker_queue_is_full() {
let (gate, started, release, finished) = open_worker::prepare_gate();
let worker = open_worker::OpenWorker::new(Some(gate)).expect("worker");
let pdf = Arc::new(fixture(1));
let open_context = pdfcraft_engine::Session::new().open_context();
// Fill the worker queue to its limit while the gate holds the worker on the first job.
let _first =
worker.submit(1, open_context.clone(), "first.pdf".into(), None, None, open_worker::Input::Bytes(pdf.clone())).expect("submit first");
started.recv_timeout(Duration::from_secs(5)).expect("first job reached gate");
for token in 2..=(open_worker::MAX_QUEUED_OPENS as u64 + 1) {
worker
.submit(token, open_context.clone(), format!("doc-{token}.pdf"), None, None, open_worker::Input::Bytes(pdf.clone()))
.expect("queue up to limit");
}
// At capacity, an ordinary submit fails with the capacity error.
let overflow = worker.submit(999, open_context.clone(), "overflow.pdf".into(), None, None, open_worker::Input::Bytes(pdf.clone()));
assert_eq!(overflow.err(), Some("too many documents are waiting to open".into()));
// A priority submit (such as a password prompt retry) succeeds and enters the queue.
let priority_token = 1000;
let priority_recv = worker.submit_priority(
priority_token,
open_context.clone(),
"password-retry.pdf".into(),
None,
Some(zeroize::Zeroizing::new("secret".into())),
open_worker::Input::Bytes(pdf.clone()),
);
assert!(priority_recv.is_ok(), "priority submit must bypass the queued opens limit");
// Release the first job; the priority job must be processed next because it was pushed to the front.
release.send(()).expect("release first");
finished.recv_timeout(Duration::from_secs(5)).expect("first finished");
started.recv_timeout(Duration::from_secs(5)).expect("priority job reached gate next");
release.send(()).expect("release priority");
finished.recv_timeout(Duration::from_secs(5)).expect("priority finished");
let priority_result = priority_recv.unwrap().recv_timeout(Duration::from_secs(5)).expect("priority result");
assert_eq!(priority_result.token, priority_token);
}
/// A password prompt keeps a place in the worker's bounded queue for its retry: files named after
/// the protected one fill the rest and wait in order, and the retry is queued, not refused as
/// "too many documents are waiting to open" while the worker has yet to take the others.
+14 -9
View File
@@ -63,13 +63,13 @@ pub fn is_paper_name(name: &str) -> bool {
impl PrintDraft {
/// The paper list: the standard sizes, then the custom ones.
pub fn paper_count(&self) -> usize {
PAPERS.len() + self.custom_papers.len()
self.papers.len() + self.custom_papers.len()
}
/// The chosen paper's size (points, portrait).
pub fn paper_size(&self) -> (f64, f64) {
match self.paper.checked_sub(PAPERS.len()) {
None => PAPERS[self.paper.min(PAPERS.len() - 1)].1,
match self.paper.checked_sub(self.papers.len()) {
None => self.papers.get(self.paper).map_or(PAPERS[0].1, |p| p.1),
Some(c) => self.custom_papers.get(c).map_or(PAPERS[0].1, |p| (p.width, p.height)),
}
}
@@ -81,20 +81,23 @@ impl PrintDraft {
/// The name of the paper at `index` in the list; the first size's for a stale index.
pub fn paper_name_at(&self, index: usize) -> String {
match index.checked_sub(PAPERS.len()) {
None => PAPERS[index.min(PAPERS.len() - 1)].0.to_string(),
match index.checked_sub(self.papers.len()) {
None => self.papers.get(index).map_or_else(|| PAPERS[0].0.to_string(), |p| p.0.clone()),
Some(c) => self.custom_papers.get(c).map_or_else(|| PAPERS[0].0.to_string(), |p| p.name.clone()),
}
}
/// The index of the paper called `name`, standard or custom.
pub fn paper_index(&self, name: &str) -> Option<usize> {
PAPERS.iter().position(|(n, _)| *n == name).or_else(|| self.custom_papers.iter().position(|p| p.name == name).map(|i| PAPERS.len() + i))
self.papers
.iter()
.position(|(n, _)| *n == name)
.or_else(|| self.custom_papers.iter().position(|p| p.name == name).map(|i| self.papers.len() + i))
}
/// True when the chosen paper is a custom one (which can be removed).
pub fn paper_is_custom(&self) -> bool {
self.paper >= PAPERS.len()
self.paper >= self.papers.len()
}
/// Add (or replace, by name) a custom paper and choose it.
@@ -104,12 +107,14 @@ impl PrintDraft {
self.custom_papers.remove(0);
}
self.custom_papers.push(paper);
self.paper = PAPERS.len() + self.custom_papers.len() - 1;
// Add is an explicit choice too: an outstanding driver read must not replace it.
self.paper_overridden = true;
self.paper = self.papers.len() + self.custom_papers.len() - 1;
}
/// Take the chosen custom paper off the list; the dialog goes back to the first size.
pub fn remove_custom_paper(&mut self) {
if let Some(i) = self.paper.checked_sub(PAPERS.len())
if let Some(i) = self.paper.checked_sub(self.papers.len())
&& i < self.custom_papers.len()
{
self.custom_papers.remove(i);
+135 -5
View File
@@ -6,7 +6,7 @@
#[cfg(not(target_arch = "wasm32"))]
use crate::file_filter::AddFilterAnyCase as _;
use egui::{Color32, Pos2, Rect, Stroke, pos2, vec2};
use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool};
use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PageOrder, SizeMode, Subset, spool};
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, widgets};
@@ -43,6 +43,14 @@ pub type PrinterSource = std::sync::Arc<dyn Fn() -> Vec<spool::Printer> + Send +
/// UI language) unless a test stands in. Called off the GUI thread.
pub type OptionsSource = std::sync::Arc<dyn Fn(&str) -> Vec<spool::PrinterOption> + Send + Sync>;
/// Where a printer's configured default paper comes from: the spooler's DEVMODE / driver settings
/// unless a test stands in.
pub type DefaultPaperSource = std::sync::Arc<dyn Fn(&str) -> Option<(String, (f64, f64))> + Send + Sync>;
/// Where a printer's supported paper sizes come from: the spooler driver capabilities
/// unless a test stands in.
pub type PrinterPapersSource = std::sync::Arc<dyn Fn(&str) -> Vec<spool::Paper> + Send + Sync>;
/// What takes a print job in place of the system spooler: a test's stand-in, which gets the
/// print-ready PDF and the job and answers as the spooler would. Called off the GUI thread.
pub type Spooler = std::sync::Arc<dyn Fn(&[u8], &spool::Job) -> Result<String, String> + Send + Sync>;
@@ -64,6 +72,11 @@ pub fn system_two_sided() -> TwoSidedSource {
std::sync::Arc::new(spool::two_sided)
}
#[cfg(not(target_arch = "wasm32"))]
type PaperInfo = (Vec<spool::Paper>, Option<spool::Paper>);
#[cfg(not(target_arch = "wasm32"))]
type PendingPaperInfo = (String, std::sync::mpsc::Receiver<PaperInfo>);
/// The Print dialog's work in the background: the printer list being read, and the jobs being
/// spooled, oldest first.
#[derive(Default)]
@@ -81,6 +94,12 @@ pub(crate) struct PrintTasks {
/// The driver options being read, per printer (`read_options_in_background`).
#[cfg(not(target_arch = "wasm32"))]
options: Vec<(String, std::sync::mpsc::Receiver<Vec<spool::PrinterOption>>)>,
/// The papers and default paper being read in the background, per printer (`read_papers_in_background`).
#[cfg(not(target_arch = "wasm32"))]
papers: Vec<PendingPaperInfo>,
/// Cached papers and default paper per printer so switching printers avoids re-querying.
#[cfg(not(target_arch = "wasm32"))]
papers_cache: std::collections::HashMap<String, PaperInfo>,
/// The browser print being drawn, a sheet a frame (`web_print`).
#[cfg(target_arch = "wasm32")]
web_job: Option<crate::web_print::WebPrintJob>,
@@ -162,6 +181,7 @@ impl Drop for WakeOnExit {
}
impl PdfCraftApp {
/// Open the Print dialog, preselecting the default printer and reading papers in the background.
pub fn open_print(&mut self) {
let Some((i, _)) = self.active_ids() else { return };
let (printers, printers_loading) = match self.printer_source.clone() {
@@ -222,7 +242,7 @@ impl PdfCraftApp {
printers_loading,
printer_picked: picked,
units: self.units,
printer,
printer: printer.clone(),
current_page: current,
sheet: 0,
selected,
@@ -234,6 +254,10 @@ impl PdfCraftApp {
if changed {
self.print_draft.apply_setup();
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(p) = printer {
self.read_papers_in_background(p);
}
self.dialog = Some(crate::Dialog::Print);
}
@@ -281,6 +305,7 @@ impl PdfCraftApp {
self.print_draft.crop_pages = Some(pages);
}
/// Read the printer list in the background off the GUI thread; `poll_print` picks it up.
#[cfg(not(target_arch = "wasm32"))]
fn list_printers_in_background(&mut self, source: PrinterSource) {
// A list already being read is as fresh as a new one would be.
@@ -337,12 +362,102 @@ impl PdfCraftApp {
}
}
/// Read `printer`'s papers and default paper off the GUI thread (#5861); `poll_print` picks them up.
/// A read already under way for the same printer is left to finish.
#[cfg(not(target_arch = "wasm32"))]
fn read_papers_in_background(&mut self, printer: String) {
if let Some((papers, default_paper)) = self.print_tasks.papers_cache.get(&printer).cloned() {
let d = &mut self.print_draft;
if d.printer.as_deref() == Some(printer.as_str()) {
d.apply_printer_papers(papers);
if !d.paper_overridden {
d.apply_default_paper(default_paper);
}
}
}
self.print_draft.papers_loading = Some(printer.clone());
if self.print_tasks.papers.iter().any(|(p, _)| *p == printer) {
return;
}
let papers_source = self.printer_papers_source.clone();
let default_source = self.default_paper_source.clone();
let (tx, rx) = std::sync::mpsc::channel();
let wake = WakeOnExit(self.ctx.clone());
let name = printer.clone();
let spawned = std::thread::Builder::new().name("printer papers".into()).spawn(move || {
let _wake = wake;
let tx = tx;
let info = match (&papers_source, &default_source) {
(None, None) => spool::printer_paper_info(&name),
(papers_s, def_s) => {
let papers = match papers_s {
Some(s) => s(&name),
None => spool::printer_papers(&name),
};
let def = match def_s {
Some(s) => s(&name),
None => spool::printer_default_paper(&name),
};
(papers, def)
}
};
let _ = tx.send(info);
});
match spawned {
Ok(_) => self.print_tasks.papers.push((printer, rx)),
Err(_) => {
let d = &mut self.print_draft;
if d.papers_loading.as_deref() == Some(printer.as_str()) {
d.papers_loading = None;
}
}
}
}
/// The papers and default paper read for `printer` arrived, or the read died.
#[cfg(not(target_arch = "wasm32"))]
fn receive_papers(&mut self, printer: String, papers: Vec<spool::Paper>, default_paper: Option<spool::Paper>) {
if !papers.is_empty() || default_paper.is_some() {
self.print_tasks.papers_cache.insert(printer.clone(), (papers.clone(), default_paper.clone()));
}
let d = &mut self.print_draft;
if d.papers_loading.as_deref() == Some(printer.as_str()) {
d.papers_loading = None;
}
if d.printer.as_deref() == Some(printer.as_str()) {
if !papers.is_empty() {
d.apply_printer_papers(papers);
}
if !d.paper_overridden && default_paper.is_some() {
d.apply_default_paper(default_paper);
}
}
}
/// Pick up a printer list, driver options or a spooled job that finished (each frame).
pub(crate) fn poll_print(&mut self) {
#[cfg(target_arch = "wasm32")]
self.poll_web_print();
#[cfg(not(target_arch = "wasm32"))]
{
if let Some(printer) = self.print_draft.read_papers_for.take() {
self.read_papers_in_background(printer);
}
let mut arrived_papers = Vec::new();
self.print_tasks.papers.retain(|(printer, rx)| match rx.try_recv() {
Ok((papers, def)) => {
arrived_papers.push((printer.clone(), papers, def));
false
}
Err(std::sync::mpsc::TryRecvError::Empty) => true,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
arrived_papers.push((printer.clone(), Vec::new(), None));
false
}
});
for (printer, papers, def) in arrived_papers {
self.receive_papers(printer, papers, def);
}
if let Some(printer) = self.print_draft.read_options_for.take() {
self.read_options_in_background(printer);
}
@@ -373,16 +488,27 @@ impl PdfCraftApp {
self.print_tasks.printers = None;
let d = &mut self.print_draft;
// A picked printer that is no longer listed falls back to the default too.
if !d.printer_picked || !offered(d.printer.as_deref(), &printers) {
let changed = if !d.printer_picked || !offered(d.printer.as_deref(), &printers) {
d.printer_picked = false;
let printer = default_printer(d, &printers);
if printer != d.printer {
let changed = printer != d.printer;
if changed {
d.printer = printer;
d.apply_setup();
}
}
changed
} else {
false
};
d.printers = printers;
d.printers_loading = false;
if changed {
d.reset_unoverridden_custom_paper();
d.apply_printer_papers(Vec::new());
if let Some(p) = d.printer.clone() {
self.read_papers_in_background(p);
}
}
}
}
self.poll_two_sided();
@@ -486,6 +612,7 @@ impl PdfCraftApp {
self.print_tasks.two_sided.push((name, rx));
}
/// Report the outcome of a finished print job to the user.
fn report_print_job(&mut self, printer: &str, result: Result<String, String>) {
match result {
Ok(msg) if msg.is_empty() => self.notify(crate::i18n::fmt(tl!("Sent to {printer}"), &[("printer", printer)])),
@@ -498,6 +625,9 @@ impl PdfCraftApp {
/// a preset path returns `true` once the save picker is showing; the file is written on a
/// later frame, when the user has chosen where.
pub fn print_now(&mut self) -> bool {
if still_looking(&self.print_draft) || self.print_draft.papers_loading() {
return false;
}
// What's typed in a form field is part of what's printed (#166).
if !self.commit_form_typing() {
return false;
+61 -10
View File
@@ -1,7 +1,7 @@
use super::draft::{CUSTOM_SCALE, Handling, PrintDraft, Which};
use super::{
Binding, BookletSubset, Color32, Content, Orientation, PAPERS, PREVIEW_BOX, PREVIEW_H, PREVIEW_W, PageOrder, Pos2, Rect, SizeMode, Stroke,
Subset, Tokens, pos2, print, printer_label, spool, still_looking, theme, vec2, widgets,
Binding, BookletSubset, Color32, Content, Orientation, PREVIEW_BOX, PREVIEW_H, PREVIEW_W, PageOrder, Pos2, Rect, SizeMode, Stroke, Subset,
Tokens, pos2, print, printer_label, spool, still_looking, theme, vec2, widgets,
};
/// The narrowest the title is still worth cutting: an ellipsis and a couple of pixels at the title's
@@ -201,6 +201,11 @@ pub(crate) fn body(
d.driver_error = None;
// What this printer was last printed with (#2001).
d.apply_setup();
d.reset_unoverridden_custom_paper();
d.apply_printer_papers(Vec::new());
if let Some(p) = d.printer.clone() {
d.read_papers_for = Some(p);
}
}
ui.end_row();
// The spooler answered with no printer at all (#160): say so, rather than
@@ -247,23 +252,35 @@ pub(crate) fn body(
let paper_label = ui.label(tl!("Paper:"));
d.paper = d.paper.min(d.paper_count().saturating_sub(1));
let before = d.paper;
let mut paper_clicked = false;
let detecting = d.papers_loading();
ui.horizontal(|ui| {
// A standard size's name is translated; a custom paper keeps the name it was given.
let shown = if d.paper_is_custom() { d.paper_name() } else { tl!(d.paper_name().as_str()).to_string() };
craft_ui::ComboBox::from_id_salt("paper").selected_text(shown).width(260.0).show_ui(ui, |ui| {
for (i, (name, _)) in PAPERS.iter().enumerate() {
ui.selectable_value(&mut d.paper, i, tl!(name));
let shown = if d.paper_is_custom() { d.paper_name().to_string() } else { tl!(&d.paper_name()).to_string() };
craft_ui::ComboBox::from_id_salt("paper").selected_text(shown).width(180.0).show_ui(ui, |ui| {
if detecting {
ui.add_enabled(false, egui::Label::new(tl!("Detecting paper…")));
}
for (i, (name, _)) in d.papers.iter().enumerate() {
if ui.selectable_value(&mut d.paper, i, tl!(name)).clicked() {
paper_clicked = true;
}
}
if !d.custom_papers.is_empty() {
ui.separator();
}
for (i, p) in d.custom_papers.iter().enumerate() {
let label = format!("{} ({})", p.name, d.units.size(p.width, p.height));
ui.selectable_value(&mut d.paper, PAPERS.len() + i, label);
if ui.selectable_value(&mut d.paper, d.papers.len() + i, label).clicked() {
paper_clicked = true;
}
}
})
.response
.labelled_by(paper_label.id);
if detecting {
ui.label(egui::RichText::new(tl!("Detecting…")).small().color(t.text_muted));
}
if ui.button(tl!("Custom…")).on_hover_text(tl!("Add a paper size of your own to the list")).clicked() {
d.custom_new = match d.custom_new {
Some(_) => None,
@@ -272,10 +289,38 @@ pub(crate) fn body(
}
if d.paper_is_custom() && ui.button(tl!("Remove")).on_hover_text(tl!("Take this paper size off the list")).clicked() {
d.remove_custom_paper();
paper_clicked = true;
}
let page_setup_tip = if spool::HAS_PRINTER_PREFERENCES {
tl!("Selects paper size and orientation from the printer driver. For paper tray, print quality or other driver defaults, use Properties…")
} else {
tl!("Page setup dialog is not available on this system.")
};
let page_setup = ui
.add_enabled(d.printer.is_some() && !browser && spool::HAS_PRINTER_PREFERENCES, egui::Button::new(tl!("Page Setup…")))
.on_hover_text(page_setup_tip);
if page_setup.clicked()
&& let Some(p) = d.printer.clone()
{
d.driver_error = None;
#[cfg(windows)]
let parent = winsafe::HWND::GetActiveWindow().map(|h| h.ptr() as isize);
#[cfg(not(windows))]
let parent = None;
match spool::show_printer_properties(&p, parent) {
Ok(Some((name, size))) => {
d.apply_driver_paper(name, size);
}
Ok(None) => {}
Err(e) => d.driver_error = Some(e.to_string()),
}
}
});
if paper_clicked || d.paper != before {
d.paper_overridden = true;
}
// Label stock gets the label layout (cut, turned upright, fitted).
if d.paper != before && d.paper == print::LABEL_PAPER && d.which != Which::Area {
if paper_clicked && d.paper != before && d.paper == print::LABEL_PAPER && d.which != Which::Area {
d.paper_before_label = Some(before);
d.handling = Handling::Label;
d.sheet = 0;
@@ -308,11 +353,12 @@ pub(crate) fn body(
if let Some(&source) = sizes.get(d.current_page) {
ui.horizontal_wrapped(|ui| {
ui.label(crate::i18n::fmt(tl!("PDF page: {size}"), &[("size", &d.units.size(source.0, source.1))]));
let matched = print::matching_paper(source);
let matched = d.matching_paper(source);
if ui.add_enabled(matched.is_some(), egui::Button::new(tl!("Match page at 100%"))).clicked()
&& let Some(paper) = matched
{
d.paper = paper;
d.paper_overridden = true;
// A paper picked outright: nothing for leaving Label to give back.
d.paper_before_label = None;
d.handling = Handling::Size;
@@ -649,8 +695,13 @@ pub(crate) fn body(
let (mut go, mut cancel) = (false, false);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let looking = still_looking(d);
let waiting_paper = d.papers_loading();
let label = if d.printer.is_some() || looking { tl!("Print") } else { tl!("Save as PDF") };
if ui.add_enabled_ui(settings.is_ok() && !sheets.is_empty() && !looking, |ui| widgets::pill_button(ui, label, true)).inner.clicked() {
if ui
.add_enabled_ui(settings.is_ok() && !sheets.is_empty() && !looking && !waiting_paper, |ui| widgets::pill_button(ui, label, true))
.inner
.clicked()
{
go = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
+205 -2
View File
@@ -1,5 +1,6 @@
use super::setups::{self, PrinterSetup};
use super::{Binding, BookletSubset, Content, Layout, Orientation, PageOrder, SizeMode, Subset, print, spool};
use pdfcraft_engine::print::PAPERS;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Which {
@@ -83,6 +84,10 @@ pub struct PrintDraft {
pub custom_new: Option<(String, String, String)>,
/// The paper chosen before Label put label stock in, so leaving Label gives it back (#3564).
pub paper_before_label: Option<usize>,
/// Whether the user has explicitly chosen or overridden the paper size.
pub paper_overridden: bool,
/// The list of papers available for selection (standard PAPERS plus any detected from the driver).
pub papers: Vec<(String, (f64, f64))>,
/// The sheet shown in the preview (0-based).
pub sheet: usize,
pub current_page: usize,
@@ -105,6 +110,10 @@ pub struct PrintDraft {
pub driver_options_loading: Option<String>,
/// Properties… asked for this printer's options; the app starts the read on the next frame.
pub read_options_for: Option<String>,
/// The printer whose papers and default paper are being read in the background.
pub papers_loading: Option<String>,
/// A printer change requested reading papers; the app starts the read on the next frame.
pub read_papers_for: Option<String>,
/// Whether each printer's driver takes two-sided settings, as read in the background for
/// the printer picked (see [`TwoSidedSource`](super::TwoSidedSource)); `None` when it couldn't be read.
pub two_sided: std::collections::BTreeMap<String, Option<bool>>,
@@ -155,6 +164,8 @@ impl Default for PrintDraft {
custom_papers: Vec::new(),
custom_new: None,
paper_before_label: None,
paper_overridden: false,
papers: default_papers(),
sheet: 0,
current_page: 0,
driver_options: None,
@@ -166,13 +177,202 @@ impl Default for PrintDraft {
crop_pages: None,
driver_options_loading: None,
read_options_for: None,
papers_loading: None,
read_papers_for: None,
two_sided: Default::default(),
setups: Vec::new(),
}
}
}
/// Standard fallback paper presets.
pub fn default_papers() -> Vec<(String, (f64, f64))> {
PAPERS.iter().map(|(n, s)| (n.to_string(), *s)).collect()
}
impl PrintDraft {
/// Find the index of a paper in `self.papers` matching `size`, allowing for orientation.
pub fn matching_paper(&self, size: (f64, f64)) -> Option<usize> {
print::matching_paper(size).or_else(|| {
self.papers.iter().position(|(_, p)| {
((p.0 - size.0).abs() <= 0.5 && (p.1 - size.1).abs() <= 0.5) || ((p.0 - size.1).abs() <= 0.5 && (p.1 - size.0).abs() <= 0.5)
})
})
}
/// Whether automatic paper detection for the selected printer is in progress and unresolved.
///
/// When true, the user has not explicitly chosen or overridden the paper, and the selected
/// printer's media sizes and default paper are still being read in the background off the GUI thread.
pub fn papers_loading(&self) -> bool {
let Some(p) = self.printer.as_deref() else { return false };
!self.paper_overridden && (self.papers_loading.as_deref() == Some(p) || self.read_papers_for.as_deref() == Some(p))
}
/// Reset an automatically selected custom paper to a standard preset on printer change.
///
/// If the currently selected paper does not match any standard preset and `paper_overridden` is false,
/// resets `paper` to 0 (the standard default preset) so printer A's custom paper does not
/// carry over when switching to printer B. Also resets `paper_before_label` if it refers to
/// an unoverridden custom paper. Explicit user overrides (`paper_overridden == true`) are preserved.
pub fn reset_unoverridden_custom_paper(&mut self) {
if !self.paper_overridden {
if print::matching_paper(self.paper_size()).is_none() {
self.paper = 0;
}
if let Some(idx) = self.paper_before_label {
let is_custom = self.papers.get(idx).is_none_or(|(_, s)| print::matching_paper(*s).is_none());
if is_custom {
self.paper_before_label = Some(0);
}
}
}
}
/// Refresh `self.papers` with driver-reported media sizes, preserving the current selection.
pub fn update_printer_papers(&mut self) {
self.update_printer_papers_with(None);
}
/// Refresh `self.papers` with driver-reported media sizes from an optional source or the driver.
pub fn update_printer_papers_with(&mut self, source: Option<&super::PrinterPapersSource>) {
let detected = if let Some(ref printer) = self.printer {
if let Some(source) = source { source(printer) } else { spool::printer_papers(printer) }
} else {
Vec::new()
};
self.apply_printer_papers(detected);
}
/// Apply driver-reported media sizes to `self.papers`, preserving the current selection.
pub fn apply_printer_papers(&mut self, detected: Vec<(String, (f64, f64))>) {
let custom_paper_idx = self.paper.checked_sub(self.papers.len());
let before_label_custom = self.paper_before_label.and_then(|idx| idx.checked_sub(self.papers.len()));
let before_label = if before_label_custom.is_some() { None } else { self.paper_before_label.and_then(|idx| self.papers.get(idx).cloned()) };
let current_name = self.paper_name().to_string();
let current_size = self.paper_size();
let mut papers = default_papers();
for (name, size) in detected {
if !(size.0 >= 72.0 && size.1 >= 72.0 && size.0.is_finite() && size.1.is_finite()) {
continue;
}
let norm = (size.0.min(size.1), size.0.max(size.1));
if !papers.iter().any(|(_, s)| {
let s_norm = (s.0.min(s.1), s.0.max(s.1));
(s_norm.0 - norm.0).abs() <= 0.5 && (s_norm.1 - norm.1).abs() <= 0.5
}) {
papers.push((name, size));
}
}
self.papers = papers;
if let Some(c_idx) = custom_paper_idx {
if c_idx < self.custom_papers.len() {
self.paper = self.papers.len() + c_idx;
} else {
self.paper = 0;
}
} else if let Some(pos) =
self.papers.iter().position(|(n, s)| n == &current_name && (s.0 - current_size.0).abs() <= 0.5 && (s.1 - current_size.1).abs() <= 0.5)
{
self.paper = pos;
} else if let Some(pos) = self.papers.iter().position(|(_, s)| (s.0 - current_size.0).abs() <= 0.5 && (s.1 - current_size.1).abs() <= 0.5) {
self.paper = pos;
} else {
self.papers.push((current_name, current_size));
self.paper = self.papers.len() - 1;
}
if let Some(c_idx) = before_label_custom {
if c_idx < self.custom_papers.len() {
self.paper_before_label = Some(self.papers.len() + c_idx);
} else {
self.paper_before_label = None;
}
} else {
self.paper_before_label = before_label.map(|(name, size)| {
if let Some(pos) = self.papers.iter().position(|(n, s)| n == &name && (s.0 - size.0).abs() <= 0.5 && (s.1 - size.1).abs() <= 0.5) {
pos
} else if let Some(pos) = self.papers.iter().position(|(_, s)| (s.0 - size.0).abs() <= 0.5 && (s.1 - size.1).abs() <= 0.5) {
pos
} else {
self.papers.push((name, size));
self.papers.len() - 1
}
});
}
}
/// Auto-select the printer's configured default paper (from DEVMODE / driver settings).
pub fn auto_select_default_paper(&mut self) {
self.auto_select_default_paper_with(None);
}
/// Auto-select the printer's default paper using an optional source (or DEVMODE / driver settings).
pub fn auto_select_default_paper_with(&mut self, source: Option<&super::DefaultPaperSource>) {
if let Some(ref printer) = self.printer {
let detected = if let Some(source) = source { source(printer) } else { spool::printer_default_paper(printer) };
self.apply_default_paper(detected);
}
}
/// Apply the detected default paper.
pub fn apply_default_paper(&mut self, detected: Option<(String, (f64, f64))>) {
if let Some((name, size)) = detected
&& let Some(pos) = self.find_or_add_paper(name, size)
{
if self.handling == Handling::Label {
if !self.paper_overridden {
self.paper_before_label = Some(pos);
}
} else {
self.paper = pos;
}
}
}
/// Locate `(name, size)` in `self.papers`, or append it if missing. Returns the paper index.
pub fn find_or_add_paper(&mut self, name: String, size: (f64, f64)) -> Option<usize> {
if !(size.0 >= 72.0 && size.1 >= 72.0 && size.0.is_finite() && size.1.is_finite()) {
return None;
}
if let Some(pos) = print::matching_paper(size) {
Some(pos)
} else if !name.is_empty() {
let norm = (size.0.min(size.1), size.0.max(size.1));
if let Some(pos) = self.papers.iter().position(|(n, s)| {
n.eq_ignore_ascii_case(&name) && {
let s_norm = (s.0.min(s.1), s.0.max(s.1));
(s_norm.0 - norm.0).abs() <= 1.0 && (s_norm.1 - norm.1).abs() <= 1.0
}
}) {
Some(pos)
} else if let Some(pos) = self.matching_paper(size) {
Some(pos)
} else {
self.papers.push((name, size));
Some(self.papers.len() - 1)
}
} else if let Some(pos) = self.matching_paper(size) {
Some(pos)
} else {
let custom_name = format!("{:.1} x {:.1} pt", size.0, size.1);
self.papers.push((custom_name, size));
Some(self.papers.len() - 1)
}
}
/// Apply paper size and orientation chosen in printer properties or page setup.
pub fn apply_driver_paper(&mut self, name: String, size: (f64, f64)) {
if let Some(pos) = self.find_or_add_paper(name, size) {
self.paper_overridden = true;
self.paper = pos;
if size.0 > size.1 {
self.orientation = Orientation::Landscape;
} else if size.1 > size.0 {
self.orientation = Orientation::Portrait;
}
}
}
/// Choose Page Sizing & Handling. Label loads label stock and remembers the paper it
/// replaced; leaving Label gives that paper back, unless another one was picked meanwhile.
pub fn set_handling(&mut self, handling: Handling) {
@@ -200,7 +400,8 @@ impl PrintDraft {
// Label's stock is that job's; the printer keeps the paper Label replaced.
let paper = if self.handling == Handling::Label { self.paper_before_label.unwrap_or(self.paper) } else { self.paper };
// By name, so a custom paper is found again however the list changes.
let setup = PrinterSetup { paper: self.paper_name_at(paper), size: self.size, custom_scale: self.custom_scale, duplex: self.duplex };
let setup =
PrinterSetup { paper: self.paper_name_at(paper).to_string(), size: self.size, custom_scale: self.custom_scale, duplex: self.duplex };
setups::record(&mut self.setups, &printer, setup);
}
@@ -308,6 +509,7 @@ impl PrintDraft {
self.as_image.then_some(print::Raster { dpi: self.dpi, grayscale: self.grayscale })
}
/// Whether multiple pages per sheet should be cut and stacked into sorted piles.
fn cut_and_stack(&self) -> bool {
self.handling == Handling::Multiple && self.order == PageOrder::CutStack
}
@@ -362,7 +564,8 @@ impl PrintDraft {
}
.into_iter()
// The sheet the PDF was laid out on, not the queue's default paper.
.chain(print::media_option(self.paper))
// A user's custom label is not a keyword from the driver's media catalog.
.chain(Some(print::paper_media_option((!self.paper_is_custom()).then(|| self.paper_name()).as_deref(), self.paper_size())))
.collect(),
}
}
@@ -84,7 +84,7 @@ mod tests {
use super::*;
fn setup(paper: usize, size: SizeMode, duplex: Option<Duplex>) -> PrinterSetup {
PrinterSetup { paper: super::super::PAPERS[paper].0.to_string(), size, custom_scale: 62.5, duplex }
PrinterSetup { paper: pdfcraft_engine::print::PAPERS[paper].0.to_string(), size, custom_scale: 62.5, duplex }
}
#[test]
+553 -12
View File
@@ -729,19 +729,25 @@ fn leaving_label_gives_back_the_paper_it_replaced() {
let a4 = paper("A4");
let mut h = harness();
assert!(h.state_mut().execute("print.dialog"));
h.run_steps(3);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while (h.state().print_draft.printers_loading || h.state().print_draft.papers_loading()) && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
h.run_steps(2);
for (mode, tab) in
[(PrintHandling::Size, "Size"), (PrintHandling::Poster, "Poster"), (PrintHandling::Multiple, "Multiple"), (PrintHandling::Booklet, "Booklet")]
{
h.state_mut().print_draft.paper = a4;
h.state_mut().print_draft.paper_overridden = true;
h.state_mut().print_draft.handling = PrintHandling::Size;
h.run_steps(2);
h.get_by_role_and_label(Role::Button, "Label").click();
h.run_steps(2);
h.run_steps(3);
assert_eq!(h.state().print_draft.handling, PrintHandling::Label);
assert_eq!(h.state().print_draft.paper, LABEL_PAPER, "Label puts label stock in");
h.get_by_role_and_label(Role::Button, tab).click();
h.run_steps(2);
h.run_steps(3);
assert_eq!(h.state().print_draft.handling, mode);
assert_eq!(h.state().print_draft.paper, a4, "{tab} gives A4 back after Label");
h.get_by_value("A4");
@@ -808,21 +814,27 @@ fn a_paper_picked_from_the_menu_while_in_label_is_kept() {
let (a4, letter) = (paper("A4"), paper("US Letter"));
let mut h = harness();
assert!(h.state_mut().execute("print.dialog"));
h.run_steps(3);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while (h.state().print_draft.printers_loading || h.state().print_draft.papers_loading()) && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
h.run_steps(2);
h.state_mut().print_draft.paper = a4;
h.state_mut().print_draft.paper_overridden = true;
h.run_steps(2);
h.get_by_role_and_label(Role::Button, "Label").click();
h.run_steps(2);
h.run_steps(3);
assert_eq!(h.state().print_draft.paper, LABEL_PAPER);
h.get_by_value(PAPERS[LABEL_PAPER].0).click();
h.run_steps(2);
h.get_by_label("US Letter").scroll_to_me();
h.run_steps(4);
h.get_by_label("US Letter").click();
h.run_steps(2);
h.run_steps(3);
assert_eq!(h.state().print_draft.paper, letter);
h.get_by_role_and_label(Role::Button, "Size").click();
h.run_steps(2);
h.run_steps(3);
assert_eq!(h.state().print_draft.handling, PrintHandling::Size);
assert_eq!(h.state().print_draft.paper, letter, "the paper picked in Label is kept");
h.get_by_value("US Letter");
@@ -896,6 +908,10 @@ fn a_dead_options_read_wakes_an_idle_dialog() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
let mut h = harness();
// A real default printer starts paper and duplex workers that can wake this dialog
// between the panic flag and the options sender being dropped. Only the options worker
// should be able to satisfy the wake assertion below.
h.state_mut().printer_source = Some(Arc::new(Vec::new));
let gate = Arc::new(Mutex::new(()));
let hold = gate.lock().unwrap();
let wait = gate.clone();
@@ -916,10 +932,12 @@ fn a_dead_options_read_wakes_an_idle_dialog() {
// The preview's page rasters render on the pool and ask for frames until they arrive; on a
// loaded machine that outlasts `run`'s step budget, so the dialog is idle only after them.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while h.state().render_pending() && std::time::Instant::now() < deadline {
while (h.state().render_pending() || h.state().print_draft.printers_loading) && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(!h.state().print_draft.printers_loading, "printer discovery finished before the idle assertion");
assert!(!h.state().render_pending(), "the preview finished before the idle assertion");
h.run();
assert!(!h.ctx.has_requested_repaint(), "the dialog is idle while the read waits");
drop(hold);
@@ -1010,12 +1028,42 @@ fn paper_choice_survives_cancel_and_settings_restore() {
let saved = h.state().persist();
let json: serde_json::Value = serde_json::from_str(&saved).unwrap();
assert_eq!(json["print_paper"], "A4", "store the canonical name, not its position");
assert_eq!(json["print_paper_overridden"], true, "explicitly chosen paper records override flag");
let mut app = PdfCraftApp::new();
app.restore(&saved);
// Grayscale is one of the preferences remembered across restarts (#2001); copies, pages and
// orientation are this job's and are not saved.
let expected = pdfcraft_ui_egui::PrintDraft { paper: a4, grayscale: true, ..Default::default() };
let expected = pdfcraft_ui_egui::PrintDraft { paper: a4, grayscale: true, paper_overridden: true, ..Default::default() };
assert_eq!(app.print_draft, expected, "only the paper and the remembered preferences persist");
// Default draft without override round-trips with paper_overridden remaining false.
let default_app = PdfCraftApp::new();
let default_saved = default_app.persist();
let default_json: serde_json::Value = serde_json::from_str(&default_saved).unwrap();
assert_eq!(default_json["print_paper_overridden"], false);
let mut restored_default = PdfCraftApp::new();
restored_default.restore(&default_saved);
assert!(!restored_default.print_draft.paper_overridden);
// Opening print with a printer default paper selects that paper when paper_overridden is false.
let mut default_h = harness();
use pdfcraft_engine::print::spool;
use std::sync::Arc;
default_h.state_mut().printer_source =
Some(Arc::new(|| vec![spool::Printer { name: "Office Printer".into(), default: true, two_sided: None, description: String::new() }]));
default_h.state_mut().printer_papers_source = Some(Arc::new(|_| vec![("A4".into(), (595.28, 841.89))]));
default_h.state_mut().default_paper_source =
Some(Arc::new(|printer: &str| if printer == "Office Printer" { Some(("A4".into(), (595.28, 841.89))) } else { None }));
assert!(default_h.state_mut().execute("print.dialog"));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while (default_h.state().print_draft.printers_loading || default_h.state().print_draft.papers_loading.is_some())
&& std::time::Instant::now() < deadline
{
default_h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(default_h.state().print_draft.paper, a4);
assert!(!default_h.state().print_draft.paper_overridden);
let mut restarted = harness_with_settings(Some(saved));
assert!(restarted.state_mut().execute("print.dialog"));
restarted.run_steps(3);
@@ -1043,6 +1091,7 @@ fn paper_restore_accepts_canonical_names_and_ignores_invalid_settings() {
let mut app = PdfCraftApp::new();
app.restore(&serde_json::json!({"print_paper": name}).to_string());
assert_eq!(app.print_draft.paper, index);
assert!(!app.print_draft.paper_overridden);
assert_eq!(serde_json::from_str::<serde_json::Value>(&app.persist()).unwrap()["print_paper"], *name);
}
for invalid in [
@@ -1074,11 +1123,382 @@ fn paper_restore_accepts_canonical_names_and_ignores_invalid_settings() {
assert_eq!(serde_json::from_str::<serde_json::Value>(&app.persist()).unwrap()["print_paper"], serde_json::Value::Null);
}
#[test]
fn legacy_settings_restore_unsets_override_and_open_selects_printer_default() {
use pdfcraft_engine::print::{PAPERS, spool};
use std::sync::Arc;
let letter = PAPERS.iter().position(|(name, _)| *name == "US Letter").unwrap();
let a4 = PAPERS.iter().position(|(name, _)| *name == "A4").unwrap();
let mut h = harness();
let legacy = serde_json::json!({
"print_paper": "US Letter",
})
.to_string();
h.state_mut().restore(&legacy);
assert_eq!(h.state().print_draft.paper, letter);
assert!(!h.state().print_draft.paper_overridden, "legacy settings restore leaves paper_overridden as false");
h.state_mut().printer_source =
Some(Arc::new(|| vec![spool::Printer { name: "Office Printer".into(), default: true, two_sided: None, description: String::new() }]));
h.state_mut().printer_papers_source = Some(Arc::new(|_| vec![("A4".into(), (595.28, 841.89))]));
h.state_mut().default_paper_source =
Some(Arc::new(|printer: &str| if printer == "Office Printer" { Some(("A4".into(), (595.28, 841.89))) } else { None }));
assert!(h.state_mut().execute("print.dialog"));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while (h.state().print_draft.printers_loading || h.state().print_draft.papers_loading.is_some()) && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(h.state().print_draft.paper, a4);
assert!(!h.state().print_draft.paper_overridden);
}
#[test]
fn auto_select_default_paper_leaves_label_stock_unchanged() {
let mut draft = pdfcraft_ui_egui::PrintDraft {
printer: Some("Office Printer".into()),
handling: pdfcraft_ui_egui::PrintHandling::Label,
paper: pdfcraft_engine::print::LABEL_PAPER,
..Default::default()
};
let a4 = pdfcraft_engine::print::PAPERS.iter().position(|(name, _)| *name == "A4").unwrap();
let source: pdfcraft_ui_egui::DefaultPaperSource = std::sync::Arc::new(move |_| Some(("A4".into(), (595.28, 841.89))));
draft.auto_select_default_paper_with(Some(&source));
assert_eq!(draft.paper, pdfcraft_engine::print::LABEL_PAPER);
assert_eq!(draft.paper_before_label, Some(a4));
}
#[test]
fn auto_select_default_paper_preserves_user_orientation() {
let mut draft = pdfcraft_ui_egui::PrintDraft {
printer: Some("Office Printer".into()),
orientation: pdfcraft_engine::print::Orientation::Landscape,
..Default::default()
};
let source: pdfcraft_ui_egui::DefaultPaperSource = std::sync::Arc::new(move |_| Some(("A4".into(), (595.28, 841.89))));
draft.auto_select_default_paper_with(Some(&source));
assert_eq!(draft.orientation, pdfcraft_engine::print::Orientation::Landscape);
}
#[test]
fn cached_papers_apply_immediately_and_refresh_in_background() {
use pdfcraft_engine::print::spool;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = calls.clone();
let mut h = harness();
h.state_mut().printer_source =
Some(Arc::new(|| vec![spool::Printer { name: "Cached Printer".into(), default: true, two_sided: None, description: String::new() }]));
h.state_mut().printer_papers_source = Some(Arc::new(move |_| {
calls_clone.fetch_add(1, Ordering::SeqCst);
vec![("A4".into(), (595.28, 841.89))]
}));
h.state_mut().default_paper_source =
Some(Arc::new(|printer: &str| if printer == "Cached Printer" { Some(("A4".into(), (595.28, 841.89))) } else { None }));
assert!(h.state_mut().execute("print.dialog"));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while (h.state().print_draft.printers_loading || h.state().print_draft.papers_loading.is_some()) && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(calls.load(Ordering::SeqCst), 1);
let a4 = h.state().print_draft.paper;
h.state_mut().dialog = None;
assert!(h.state_mut().execute("print.dialog"));
assert_eq!(h.state().print_draft.paper, a4);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while h.state().print_draft.papers_loading.is_some() && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[test]
fn uncached_printer_switch_resets_driver_papers_to_standard_presets() {
use pdfcraft_engine::print::spool;
use std::sync::Arc;
let mut h = harness();
h.state_mut().printer_source = Some(Arc::new(|| {
vec![
spool::Printer { name: "Label Printer".into(), default: true, two_sided: None, description: String::new() },
spool::Printer { name: "Laser Printer".into(), default: false, two_sided: None, description: String::new() },
]
}));
h.state_mut().printer_papers_source = Some(Arc::new(|printer: &str| {
if printer == "Label Printer" {
vec![("Special Label 300x500".into(), (300.0, 500.0))]
} else {
vec![("Laser 350x550".into(), (350.0, 550.0))]
}
}));
h.state_mut().default_paper_source =
Some(Arc::new(|printer: &str| if printer == "Label Printer" { Some(("Special Label 300x500".into(), (300.0, 500.0))) } else { None }));
assert!(h.state_mut().execute("print.dialog"));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while (h.state().print_draft.printers_loading || h.state().print_draft.papers_loading.is_some()) && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(h.state().print_draft.paper_name(), "Special Label 300x500");
assert!(!h.state().print_draft.paper_overridden);
// Switch printer via printer-change site: reset unoverridden custom paper and apply_printer_papers(Vec::new()) clears old papers immediately
h.state_mut().print_draft.printer = Some("Laser Printer".into());
h.state_mut().print_draft.reset_unoverridden_custom_paper();
h.state_mut().print_draft.apply_printer_papers(Vec::new());
assert_eq!(h.state().print_draft.paper_name(), "US Letter");
assert!(
!h.state().print_draft.papers.iter().any(|(n, _)| n == "Special Label 300x500"),
"Custom label paper from previous printer cleared immediately"
);
assert!(h.state().print_draft.papers.iter().any(|(n, _)| n == "US Letter"), "Standard presets remain available");
// Explicit override is preserved
h.state_mut().print_draft.papers.push(("User Custom 400x600".into(), (400.0, 600.0)));
h.state_mut().print_draft.paper = h.state().print_draft.papers.len() - 1;
h.state_mut().print_draft.paper_overridden = true;
h.state_mut().print_draft.reset_unoverridden_custom_paper();
assert_eq!(h.state().print_draft.paper_name(), "User Custom 400x600");
}
#[test]
fn printer_list_refresh_resets_unoverridden_custom_paper_when_default_printer_changes() {
use pdfcraft_engine::print::spool;
use std::sync::Arc;
let mut h = harness();
let initial_printers = vec![
spool::Printer { name: "Label Printer".into(), default: true, two_sided: None, description: String::new() },
spool::Printer { name: "Laser Printer".into(), default: false, two_sided: None, description: String::new() },
];
let updated_printers = vec![spool::Printer { name: "Laser Printer".into(), default: true, two_sided: None, description: String::new() }];
let calls = std::sync::atomic::AtomicUsize::new(0);
let initial_p = initial_printers.clone();
let updated_p = updated_printers.clone();
h.state_mut().printer_source =
Some(Arc::new(move || if calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { initial_p.clone() } else { updated_p.clone() }));
h.state_mut().printer_papers_source = Some(Arc::new(|printer: &str| {
if printer == "Label Printer" {
vec![("Special Label 300x500".into(), (300.0, 500.0))]
} else {
vec![("Laser 350x550".into(), (350.0, 550.0))]
}
}));
h.state_mut().default_paper_source =
Some(Arc::new(|printer: &str| if printer == "Label Printer" { Some(("Special Label 300x500".into(), (300.0, 500.0))) } else { None }));
assert!(h.state_mut().execute("print.dialog"));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while (h.state().print_draft.printers_loading || h.state().print_draft.papers_loading.is_some()) && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(h.state().print_draft.paper_name(), "Special Label 300x500");
// Trigger background printer list refresh with the updated printers list (where Laser Printer is default)
h.state_mut().open_print();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while (h.state().print_draft.printers_loading || h.state().print_draft.papers_loading.is_some()) && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
// Laser Printer has no default paper, so paper should be a standard preset ("US Letter"), not Printer A's custom paper
assert_ne!(h.state().print_draft.paper_name(), "Special Label 300x500");
assert_eq!(h.state().print_draft.paper_name(), "US Letter");
}
#[test]
fn print_button_waits_for_automatic_paper_detection_before_submission() {
use pdfcraft_engine::print::spool;
use std::sync::{Arc, Mutex, mpsc};
let mut h = harness();
let (release_tx, release_rx) = mpsc::channel();
let (started_tx, started_rx) = mpsc::channel();
let release_rx = Arc::new(Mutex::new(release_rx));
let started_tx = Arc::new(Mutex::new(started_tx));
h.state_mut().printer_source =
Some(Arc::new(|| vec![spool::Printer { name: "Label 4x6 Printer".into(), default: true, two_sided: None, description: String::new() }]));
let rx_clone = Arc::clone(&release_rx);
let tx_clone = Arc::clone(&started_tx);
h.state_mut().printer_papers_source = Some(Arc::new(move |_| {
let _ = tx_clone.lock().unwrap().send(());
let _ = rx_clone.lock().unwrap().recv();
vec![("Shipping Label 4x6".into(), (288.0, 432.0))]
}));
h.state_mut().default_paper_source = Some(Arc::new(|_| Some(("Shipping Label 4x6".into(), (288.0, 432.0)))));
assert!(h.state_mut().execute("print.dialog"));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while h.state().print_draft.printers_loading && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
// Wait until background paper worker has reached the channel gate
let _ = started_rx.recv_timeout(std::time::Duration::from_secs(5));
// While background read is pending and paper is not manually overridden:
assert!(h.state().print_draft.papers_loading(), "papers_loading must be true while automatic detection is pending");
assert!(!h.state_mut().print_now(), "print_now must be refused while paper detection is pending");
// Overriding paper manually resolves the pending state and enables submission immediately
h.state_mut().print_draft.paper_overridden = true;
assert!(!h.state().print_draft.papers_loading(), "papers_loading becomes false once paper is overridden");
assert!(h.state_mut().print_now(), "print_now succeeds when user has chosen a paper");
// Unblock the worker so the thread finishes cleanly
let _ = release_tx.send(());
}
#[test]
fn gated_paper_detection_enables_print_after_worker_finishes() {
use pdfcraft_engine::print::spool;
use std::sync::{Arc, Mutex, mpsc};
let mut h = harness();
let (release_tx, release_rx) = mpsc::channel();
let (started_tx, started_rx) = mpsc::channel();
let release_rx = Arc::new(Mutex::new(release_rx));
let started_tx = Arc::new(Mutex::new(started_tx));
h.state_mut().printer_source =
Some(Arc::new(|| vec![spool::Printer { name: "Label 4x6 Printer".into(), default: true, two_sided: None, description: String::new() }]));
let rx_clone = Arc::clone(&release_rx);
let tx_clone = Arc::clone(&started_tx);
h.state_mut().printer_papers_source = Some(Arc::new(move |_| {
let _ = tx_clone.lock().unwrap().send(());
let _ = rx_clone.lock().unwrap().recv();
vec![("Shipping Label 4x6".into(), (288.0, 432.0))]
}));
h.state_mut().default_paper_source = Some(Arc::new(|_| Some(("Shipping Label 4x6".into(), (288.0, 432.0)))));
assert!(h.state_mut().execute("print.dialog"));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while h.state().print_draft.printers_loading && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
let _ = started_rx.recv_timeout(std::time::Duration::from_secs(5));
assert!(h.state().print_draft.papers_loading());
assert!(!h.state_mut().print_now());
// Release the worker
let _ = release_tx.send(());
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while h.state().print_draft.papers_loading() && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(!h.state().print_draft.papers_loading());
assert_eq!(h.state().print_draft.paper_size(), (288.0, 432.0));
assert!(h.state_mut().print_now());
}
#[test]
fn gated_paper_detection_blocks_print_on_printer_switch_reuse_until_resolved() {
use pdfcraft_engine::print::spool;
use std::sync::{Arc, Mutex, mpsc};
let mut h = harness();
let (release_a_tx, release_a_rx) = mpsc::channel();
let (started_a_tx, started_a_rx) = mpsc::channel();
let release_a_rx = Arc::new(Mutex::new(release_a_rx));
let started_a_tx = Arc::new(Mutex::new(started_a_tx));
let (release_b_tx, release_b_rx) = mpsc::channel();
let (started_b_tx, started_b_rx) = mpsc::channel();
let release_b_rx = Arc::new(Mutex::new(release_b_rx));
let started_b_tx = Arc::new(Mutex::new(started_b_tx));
h.state_mut().printer_source = Some(Arc::new(|| {
vec![
spool::Printer { name: "Printer A".into(), default: true, two_sided: None, description: String::new() },
spool::Printer { name: "Printer B".into(), default: false, two_sided: None, description: String::new() },
]
}));
let rx_a = Arc::clone(&release_a_rx);
let tx_a = Arc::clone(&started_a_tx);
let rx_b = Arc::clone(&release_b_rx);
let tx_b = Arc::clone(&started_b_tx);
h.state_mut().printer_papers_source = Some(Arc::new(move |p| {
if p == "Printer A" {
let _ = tx_a.lock().unwrap().send(());
let _ = rx_a.lock().unwrap().recv();
vec![("A-Paper 4x6".into(), (288.0, 432.0))]
} else {
let _ = tx_b.lock().unwrap().send(());
let _ = rx_b.lock().unwrap().recv();
vec![("B-Paper 5x7".into(), (360.0, 504.0))]
}
}));
h.state_mut().default_paper_source =
Some(Arc::new(
|p| {
if p == "Printer A" { Some(("A-Paper 4x6".into(), (288.0, 432.0))) } else { Some(("B-Paper 5x7".into(), (360.0, 504.0))) }
},
));
// 1. Open print dialog: Printer A is default and starts reading papers
assert!(h.state_mut().execute("print.dialog"));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while h.state().print_draft.printers_loading && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
let _ = started_a_rx.recv_timeout(std::time::Duration::from_secs(5));
assert!(h.state().print_draft.papers_loading());
assert!(!h.state_mut().print_now());
// 2. Switch to Printer B
h.state_mut().print_draft.printer = Some("Printer B".into());
h.state_mut().print_draft.read_papers_for = Some("Printer B".into());
h.run_steps(1);
let _ = started_b_rx.recv_timeout(std::time::Duration::from_secs(5));
assert!(h.state().print_draft.papers_loading());
assert!(!h.state_mut().print_now());
// 3. Switch back to Printer A (A's read is still in flight, reusing worker)
h.state_mut().print_draft.printer = Some("Printer A".into());
h.state_mut().print_draft.read_papers_for = Some("Printer A".into());
h.run_steps(1);
// Guard must still hold for Printer A
assert!(h.state().print_draft.papers_loading(), "papers_loading must be restored for Printer A when reusing its worker");
assert!(!h.state_mut().print_now(), "print_now must be refused while Printer A's read is still pending after switch back");
// 4. Release Printer A's read
let _ = release_a_tx.send(());
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while h.state().print_draft.papers_loading() && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(!h.state().print_draft.papers_loading());
assert_eq!(h.state().print_draft.paper_size(), (288.0, 432.0));
assert!(h.state_mut().print_now());
// Clean up Printer B worker
let _ = release_b_tx.send(());
}
#[test]
fn restored_paper_keeps_preview_and_saved_sheet_dimensions() {
use pdfcraft_engine::print::{self, PAPERS};
let a4 = PAPERS.iter().find(|(name, _)| *name == "A4").unwrap().1;
let mut h = harness_with_settings(Some(r#"{"print_paper":"A4"}"#.into()));
let mut h = harness_with_settings(Some(r#"{"print_paper":"A4","print_paper_overridden":true}"#.into()));
assert!(h.state_mut().execute("print.dialog"));
h.run_steps(3);
h.get_by_value("A4");
@@ -1114,15 +1534,78 @@ fn restored_paper_keeps_preview_and_saved_sheet_dimensions() {
fn settle(h: &mut Harness<'static, PdfCraftApp>) {
for _ in 0..500 {
h.run_steps(1);
if !h.state().print_draft.printers_loading {
if !h.state().print_draft.printers_loading && !h.state().print_draft.papers_loading() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert!(!h.state().print_draft.printers_loading, "the printer list loaded");
assert!(!h.state().print_draft.papers_loading(), "the printer papers loaded");
h.run_steps(8);
}
/// A paper explicitly added while discovery is pending stays selected after its reply and reopen.
#[test]
fn adding_custom_paper_overrides_pending_driver_default() {
use pdfcraft_engine::print::spool::Printer;
use std::sync::{Arc, Mutex, mpsc};
let mut h = harness();
let (release_tx, release_rx) = mpsc::channel();
let (started_tx, started_rx) = mpsc::channel();
let first_read = Arc::new(Mutex::new(Some(release_rx)));
h.state_mut().printer_source =
Some(Arc::new(|| vec![Printer { name: "Labeller".into(), default: true, two_sided: None, description: String::new() }]));
h.state_mut().printer_papers_source = Some(Arc::new(move |_| {
let pending = first_read.lock().unwrap().take();
if let Some(rx) = pending {
let _ = started_tx.send(());
let _ = rx.recv();
}
vec![("Driver label".into(), (288.0, 432.0))]
}));
h.state_mut().default_paper_source = Some(Arc::new(|_| Some(("Driver label".into(), (288.0, 432.0)))));
assert!(h.state_mut().execute("print.dialog"));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
h.run_steps(1);
if started_rx.try_recv().is_ok() {
break;
}
assert!(std::time::Instant::now() < deadline, "paper discovery started");
std::thread::sleep(std::time::Duration::from_millis(5));
}
h.get_by_label("Custom…").click();
h.run_steps(2);
let units = h.state().print_draft.units.per_point();
h.state_mut().print_draft.custom_new = Some(("Card".into(), format!("{}", 300.0 * units), format!("{}", 500.0 * units)));
h.run_steps(2);
h.get_by_label("Add").click();
h.run_steps(2);
assert!(h.state().print_draft.paper_overridden, "Add is an explicit paper choice");
assert!(!h.state().print_draft.papers_loading(), "the explicit choice need not wait for detection");
release_tx.send(()).unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while h.state().print_draft.papers_loading.is_some() {
assert!(std::time::Instant::now() < deadline, "paper discovery completed");
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(h.state().print_draft.paper_name(), "Card");
let (w, h_pts) = h.state().print_draft.paper_size();
assert!((w - 300.0).abs() < 0.01 && (h_pts - 500.0).abs() < 0.01);
h.state_mut().open_print();
settle(&mut h);
assert_eq!(h.state().print_draft.paper_name(), "Card", "reopening keeps the explicit choice");
}
/// A name entered by the user is not a keyword from the printer's media catalog.
#[test]
fn a_named_custom_paper_requests_its_dimensions_from_the_spooler() {
let mut d = pdfcraft_ui_egui::PrintDraft::default();
d.add_custom_paper(serde_json::from_value(serde_json::json!({"name": "Card", "width": 300.0, "height": 500.0})).unwrap());
assert_eq!(d.job("card.pdf").options, [("media".to_string(), "Custom.300x500".to_string())]);
}
/// Page setup: a paper size of one's own joins the Paper list (Custom…) and prints at its size.
/// The printer it was printed on keeps it (#2001) by name, across a restart, and Remove takes it
/// off the list; the printer's setup then leaves the paper as it is.
@@ -1134,6 +1617,8 @@ fn a_custom_paper_prints_at_its_size_and_its_printer_keeps_it() {
let attach = |h: &mut Harness<'static, PdfCraftApp>| {
h.state_mut().printer_source =
Some(Arc::new(|| vec![Printer { name: "Labeller".into(), default: true, two_sided: None, description: String::new() }]));
h.state_mut().printer_papers_source = Some(Arc::new(|_| Vec::new()));
h.state_mut().default_paper_source = Some(Arc::new(|_| None));
let sent = sent.clone();
h.state_mut().spooler = Some(Arc::new(move |pdf: &[u8], _job: &Job| {
sent.lock().unwrap().push(pdf.to_vec());
@@ -1160,6 +1645,11 @@ fn a_custom_paper_prints_at_its_size_and_its_printer_keeps_it() {
assert!((w - 288.0).abs() < 0.01 && (l - 432.0).abs() < 0.01, "portrait: {w}×{l}");
}
// The custom row has closed: let the dialog settle at its new height before clicking.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while h.state().print_draft.papers_loading() && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
h.run_steps(8);
h.get_by(|n| n.label().as_deref() == Some("Print") && n.role() == Role::Button).click();
for _ in 0..400 {
@@ -1252,6 +1742,48 @@ fn a_booklet_decides_two_sided_itself() {
assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::LongEdge), "back to the choice");
}
/// #5861: The print dialog supports all papers detected from the printer driver, matches custom
/// page sizes against them, and imposes on the chosen dimensions.
#[test]
fn printer_papers_and_properties_selection() {
let mut h = harness();
assert!(h.state_mut().execute("print.dialog"));
h.run_steps(3);
// Initial state offers standard PAPERS.
assert!(h.state().print_draft.papers.len() >= 21);
assert_eq!(h.state().print_draft.paper_name(), "US Letter");
// When the driver offers additional custom papers, they are available in d.papers.
let custom_paper = ("Custom 333x444".to_string(), (333.0, 444.0));
h.state_mut().print_draft.papers.push(custom_paper.clone());
let custom_index = h.state().print_draft.papers.len() - 1;
h.state_mut().print_draft.paper = custom_index;
assert_eq!(h.state().print_draft.paper_name(), "Custom 333x444");
assert_eq!(h.state().print_draft.paper_size(), (333.0, 444.0));
// Matching page at 100% finds the custom paper
assert_eq!(h.state().print_draft.matching_paper((333.0, 444.0)), Some(custom_index));
// Imposed settings use the exact dimensions of the detected paper
let settings = h.state().print_draft.settings(1, &["1".into()]).unwrap();
assert_eq!(settings.paper, (333.0, 444.0));
// Page Setup button is present in the dialog
h.get_by_label("Page Setup…");
// Applying driver paper adapts paper selection and orientation
h.state_mut().print_draft.apply_driver_paper("4 x 6 in label (101.6 x 152.4 mm)".into(), (288.0, 432.0));
assert_eq!(h.state().print_draft.paper, pdfcraft_engine::print::LABEL_PAPER);
assert_eq!(h.state().print_draft.orientation, pdfcraft_engine::print::Orientation::Portrait);
// Landscape driver media adapts orientation to Landscape
h.state_mut().print_draft.apply_driver_paper("Landscape Custom".into(), (500.0, 300.0));
assert_eq!(h.state().print_draft.orientation, pdfcraft_engine::print::Orientation::Landscape);
assert_eq!(h.state().print_draft.paper_name(), "Landscape Custom");
}
/// Creep the field allows can still move a thick booklet's innermost pages too far; the preview
/// says to reduce it rather than showing no sheets without a reason (#6777).
#[test]
@@ -1402,6 +1934,8 @@ fn each_printer_keeps_what_it_last_printed_with_across_a_restart() {
.map(|name| Printer { name: name.into(), default: name == "Office", two_sided: None, description: String::new() })
.collect()
}));
h.state_mut().printer_papers_source = Some(Arc::new(|_| Vec::new()));
h.state_mut().default_paper_source = Some(Arc::new(|_| None));
let sent = jobs.clone();
h.state_mut().spooler = Some(Arc::new(move |_pdf: &[u8], job: &Job| {
sent.lock().unwrap().push(job.clone());
@@ -1412,15 +1946,22 @@ fn each_printer_keeps_what_it_last_printed_with_across_a_restart() {
h.state_mut().dialog = None;
assert!(h.state_mut().execute("print.dialog"));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while h.state().print_draft.printers_loading && std::time::Instant::now() < deadline {
while (h.state().print_draft.printers_loading || h.state().print_draft.papers_loading()) && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(!h.state().print_draft.printers_loading);
assert!(!h.state().print_draft.papers_loading());
h.run_steps(2);
};
let print = |h: &mut Harness<'static, PdfCraftApp>| -> Job {
let before = jobs.lock().unwrap().len();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while h.state().print_draft.papers_loading() && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
h.run_steps(2);
h.get_by(|n| n.label().as_deref() == Some("Print") && n.role() == Role::Button).click();
for _ in 0..400 {
h.run_steps(1);
+14 -3
View File
@@ -8404,9 +8404,20 @@ tier = "P1"
milestone = "M10"
status = "partial"
commands = ["print.dialog"]
tools = ["doc_print"]
evidence = ["crates/ui-egui/tests/it/print.rs::a_custom_paper_prints_at_its_size_and_its_printer_keeps_it", "crates/ui-egui/src/workflows/print_paper.rs::the_paper_list_is_the_standard_sizes_then_the_custom_ones", "crates/ui-egui/src/workflows/print_paper.rs::settings_are_read_as_untrusted", "crates/ui-egui/src/workflows/print_ui/setups.rs::setups_round_trip_through_the_settings_file", "crates/ui-egui/tests/it/print_custom_paper_names.rs::a_custom_paper_cannot_take_a_standard_name", "crates/automation/tests/it/automation.rs::doc_print_takes_a_paper_size_of_its_own"]
notes = "In the Print dialog, as the rest of printing is (no separate Page Setup window): Custom… beside Paper adds a named size in the preferences' unit (1 to 200 inches a side), kept with the settings and removable. A printer printed to on a custom paper starts on it next time, with the rest of its setup (#2001), the paper kept by name. doc_print takes sizes such as 8.5x14in or 210x297mm. Missing: margins."
tools = ["doc_print", "printer_papers"]
evidence = [
"crates/ui-egui/tests/it/print.rs::printer_papers_and_properties_selection",
"crates/automation/tests/it/automation.rs::printer_papers_through_tools",
"crates/print/src/tests.rs::printer_papers_and_default_paper_api",
"crates/print/src/tests.rs::overlapping_paper_discovery_two_sided_and_queue_queries",
"crates/ui-egui/tests/it/print.rs::a_custom_paper_prints_at_its_size_and_its_printer_keeps_it",
"crates/ui-egui/src/workflows/print_paper.rs::the_paper_list_is_the_standard_sizes_then_the_custom_ones",
"crates/ui-egui/src/workflows/print_paper.rs::settings_are_read_as_untrusted",
"crates/ui-egui/src/workflows/print_ui/setups.rs::setups_round_trip_through_the_settings_file",
"crates/ui-egui/tests/it/print_custom_paper_names.rs::a_custom_paper_cannot_take_a_standard_name",
"crates/automation/tests/it/automation.rs::doc_print_takes_a_paper_size_of_its_own",
]
notes = "In the Print dialog: Custom… beside Paper adds a named size in the preferences' unit (1 to 200 inches a side), kept with the settings and removable. A printer printed to on a custom paper starts on it next time (#2001). The Page Setup button opens the driver's page setup to choose paper size and orientation. printer_papers enumerates driver-reported papers and default paper headlessly. doc_print takes sizes such as 8.5x14in or 210x297mm or driver papers. Missing: margins."
[[feature]]
id = "print.print-ready-pdf"