mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
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:
23 files changed
+1625
-111
No files matched your search
Generated
+23
-1
@@ -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"
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 == ¤t_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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user