Print: each printer keeps its paper, sizing and two-sided choice across restarts (#2001) (#6779)

After a print to a printer, its paper, page sizing (Fit, Actual size,
Shrink, Custom %) and two-sided choice are remembered for that printer and
brought back when it is chosen. The printer printed to last is preselected
after a restart while it is listed, else the system default. Saved as
print_printers, most recent first, at most 32; malformed entries are skipped.
This commit is contained in:
FredApps authored and GitHub committed 2026-11-10 00:57:33 +01:00
1 parent 0c0f0b6b56
commit 821636fbeb
8 files changed
+353 -9

No files matched your search

+5
View File
@@ -189,6 +189,8 @@ impl PdfCraftApp {
v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name));
// 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).
v["print_printers"] = crate::print_ui::setups_to_json(&self.print_draft.setups);
v.to_string()
}
@@ -316,6 +318,9 @@ impl PdfCraftApp {
{
self.print_draft.paper = paper;
}
if let Some(setups) = crate::print_ui::setups_from_json(&v["print_printers"]) {
self.print_draft.setups = setups;
}
if let Some(on) = v["highlight_fields"].as_bool() {
self.view_defaults.highlight_fields = on;
}
+3 -1
View File
@@ -59,7 +59,9 @@ mod home;
mod hotbar;
mod panels;
pub mod panes;
pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Spooler, Which as PrintWhich, system_printers};
pub use print_ui::{
Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSetup, PrinterSource, Spooler, Which as PrintWhich, system_printers,
};
pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode};
pub mod i18n;
pub mod theme;
+26 -6
View File
@@ -13,9 +13,12 @@ use crate::{PdfCraftApp, widgets};
mod body;
mod draft;
mod setups;
pub(crate) use body::body;
pub use draft::{CUSTOM_SCALE, Handling, IMAGE_DPI, PrintDraft, Which};
pub use setups::PrinterSetup;
pub(crate) use setups::{from_json as setups_from_json, to_json as setups_to_json};
/// The preview pane, and the paper inside it (12 pt inset on each side).
const PREVIEW_W: f32 = 320.0;
@@ -111,9 +114,10 @@ enum JobNews {
Done(Result<String, String>),
}
/// The printer the dialog preselects: the system default, else the first.
fn default_printer(printers: &[spool::Printer]) -> Option<String> {
printers.iter().find(|p| p.default).or(printers.first()).map(|p| p.name.clone())
/// The printer the dialog preselects: the one printed to last while it is listed (#2001), else
/// the system default, else the first.
fn default_printer(d: &PrintDraft, printers: &[spool::Printer]) -> Option<String> {
d.last_printer(printers).or_else(|| printers.iter().find(|p| p.default)).or(printers.first()).map(|p| p.name.clone())
}
/// Whether `printer` can still be chosen from `printers`: Save as PDF (`None`) always can.
@@ -149,7 +153,6 @@ impl PdfCraftApp {
}
_ => (listed_printers(), false),
};
let default = default_printer(&printers);
let current = self.views[i].current;
let selected: Vec<usize> = self.views[i].selected.iter().copied().collect();
let mut keep = std::mem::take(&mut self.print_draft);
@@ -157,9 +160,13 @@ impl PdfCraftApp {
if keep.handling == Handling::Label {
keep.set_handling(Handling::Size);
}
let default = default_printer(&keep, &printers);
// The printer picked last time, Save as PDF included, while it is still listed (#3537).
let picked = keep.printer_picked && offered(keep.printer.as_deref(), &printers);
let printer = if picked { keep.printer.clone() } else { default };
// A printer other than last time's brings back what it was last printed with (#2001);
// the same one keeps this session's choices.
let changed = printer != keep.printer;
// Picked pages are what Print is for (Acrobat's "Selected pages"); without any, the whole
// document.
let which = if selected.is_empty() { Which::All } else { Which::Selected };
@@ -202,6 +209,9 @@ impl PdfCraftApp {
which,
..keep
};
if changed {
self.print_draft.apply_setup();
}
self.dialog = Some(crate::Dialog::Print);
}
@@ -343,7 +353,11 @@ impl PdfCraftApp {
// A picked printer that is no longer listed falls back to the default too.
if !d.printer_picked || !offered(d.printer.as_deref(), &printers) {
d.printer_picked = false;
d.printer = default_printer(&printers);
let printer = default_printer(d, &printers);
if printer != d.printer {
d.printer = printer;
d.apply_setup();
}
}
d.printers = printers;
d.printers_loading = false;
@@ -441,7 +455,8 @@ impl PdfCraftApp {
return false;
}
};
match self.print_draft.printer.clone() {
let to_printer = self.print_draft.printer.is_some();
let sent = match self.print_draft.printer.clone() {
#[cfg(target_arch = "wasm32")]
Some(printer) if printer == crate::web_print::BROWSER_PRINTER => {
let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (f64::from(p.width), f64::from(p.height))).collect();
@@ -464,7 +479,12 @@ impl PdfCraftApp {
self.send_to_printer(printer, bytes, job)
}
None => self.save_print_pdf(&name, bytes),
};
// What this printer printed with is what it starts with next time (#2001).
if sent && to_printer {
self.print_draft.remember_setup();
}
sent
}
/// Spool a job off the UI thread: on Windows every sheet is rendered at the printer's
@@ -158,6 +158,8 @@ pub(crate) fn body(
d.printer_picked = true;
d.show_driver_options = false;
d.driver_error = None;
// What this printer was last printed with (#2001).
d.apply_setup();
}
ui.end_row();
// The spooler answered with no printer at all (#160): say so, rather than
@@ -1,3 +1,4 @@
use super::setups::{self, PrinterSetup};
use super::{Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, print, spool};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -93,6 +94,9 @@ 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 paper, sizing and two-sided choice each printer was last printed with, the printer
/// printed to last first; kept across restarts (#2001).
pub setups: Vec<(String, PrinterSetup)>,
}
impl Default for PrintDraft {
@@ -144,6 +148,7 @@ impl Default for PrintDraft {
crop_pages: None,
driver_options_loading: None,
read_options_for: None,
setups: Vec::new(),
}
}
}
@@ -169,6 +174,40 @@ impl PrintDraft {
}
}
/// Remember this draft's paper, sizing and two-sided choice for its printer, after printing
/// to it (#2001). Save as PDF has none to remember.
pub fn remember_setup(&mut self) {
let Some(printer) = self.printer.clone() else { return };
// 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 };
let setup = PrinterSetup { paper, size: self.size, custom_scale: self.custom_scale, duplex: self.duplex };
setups::record(&mut self.setups, &printer, setup);
}
/// Bring back what the chosen printer was last printed with, when it has been printed to
/// (#2001); a printer never printed to keeps the dialog's choices. While Label holds the
/// label stock, the printer's paper is what leaving Label gives back.
pub fn apply_setup(&mut self) {
let Some(printer) = self.printer.as_deref() else { return };
let Some(setup) = self.setups.iter().find(|(name, _)| name == printer).map(|(_, s)| *s) else { return };
self.size = setup.size;
self.custom_scale = setup.custom_scale;
self.duplex = setup.duplex;
let paper = setup.paper.min(PAPERS.len() - 1);
if self.handling == Handling::Label {
self.paper_before_label = Some(paper);
} else {
self.paper = paper;
}
self.sheet = 0;
}
/// The printer printed to last, while `printers` still lists it (#2001).
pub fn last_printer<'a>(&self, printers: &'a [spool::Printer]) -> Option<&'a spool::Printer> {
let (last, _) = self.setups.first()?;
printers.iter().find(|p| p.name == *last)
}
/// The engine settings for this draft (page count and labels from the document).
pub fn settings(&self, count: usize, labels: &[String]) -> Result<print::Settings, String> {
if self.cut_and_stack() && matches!(self.duplex, Some(spool::Duplex::LongEdge | spool::Duplex::ShortEdge)) {
@@ -0,0 +1,163 @@
//! What each printer was last printed with (#2001): its paper, page sizing and two-sided choice,
//! kept across restarts, so a label printer keeps 4 × 6 in at Actual size while an office
//! printer keeps A4 at Fit. The printer printed to last is preselected the next time Print opens.
//!
//! Copies, the pages to print, orientation and the Multiple / Booklet / Poster layouts stay one
//! job's choices, as before.
use super::{CUSTOM_SCALE, PAPERS, SizeMode, print::names, spool::Duplex};
/// At most this many printers are remembered, the most recently printed to first (settings are
/// untrusted, and a long-gone queue shouldn't stay forever).
pub const MAX_PRINTERS: usize = 32;
/// A longer printer name in the settings file is refused (CUPS and Windows queue names are far
/// shorter).
const MAX_NAME_BYTES: usize = 1024;
/// The paper, sizing and two-sided choice a printer was last printed with.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PrinterSetup {
/// `PAPERS` index.
pub paper: usize,
pub size: SizeMode,
/// The percent `SizeMode::Custom` prints at.
pub custom_scale: f64,
/// `None` = the printer's own default.
pub duplex: Option<Duplex>,
}
/// Put `setup` first for `printer`, replacing what it had, and drop the oldest past
/// [`MAX_PRINTERS`].
pub fn record(setups: &mut Vec<(String, PrinterSetup)>, printer: &str, setup: PrinterSetup) {
setups.retain(|(name, _)| name != printer);
setups.insert(0, (printer.to_string(), setup));
setups.truncate(MAX_PRINTERS);
}
/// The setups for the settings file: the paper by its canonical name (as `print_paper`), sizing
/// and two-sided by the names `doc_print` and the `print` setting use ([`names`]).
pub fn to_json(setups: &[(String, PrinterSetup)]) -> serde_json::Value {
setups
.iter()
.filter_map(|(printer, s)| {
let (paper, _) = PAPERS.get(s.paper)?;
Some(serde_json::json!({
"printer": printer,
"paper": paper,
"size": names::size_mode(s.size),
"custom_scale": s.custom_scale,
"duplex": names::duplex(s.duplex),
}))
})
.collect()
}
/// The setups read back from the settings file; `None` when it holds no list, so the caller
/// keeps what it has. An entry with an unknown or malformed value is skipped whole (a half-read
/// setup would print the label at the wrong size), as is a repeated printer.
pub fn from_json(v: &serde_json::Value) -> Option<Vec<(String, PrinterSetup)>> {
let mut out: Vec<(String, PrinterSetup)> = Vec::new();
for entry in v.as_array()?.iter() {
if out.len() >= MAX_PRINTERS {
break;
}
let Some(read) = read_entry(entry) else { continue };
if out.iter().all(|(name, _)| *name != read.0) {
out.push(read);
}
}
Some(out)
}
fn read_entry(entry: &serde_json::Value) -> Option<(String, PrinterSetup)> {
let printer = entry.get("printer")?.as_str().filter(|p| !p.is_empty() && p.len() <= MAX_NAME_BYTES)?;
let paper_name = entry.get("paper")?.as_str()?;
let paper = PAPERS.iter().position(|(name, _)| *name == paper_name)?;
let custom_scale = entry.get("custom_scale")?.as_f64().filter(|s| CUSTOM_SCALE.contains(s))?;
let size = names::parse_size_mode(entry.get("size")?.as_str()?, custom_scale)?;
let duplex = names::parse_duplex(entry.get("duplex")?.as_str()?)?;
Some((printer.to_string(), PrinterSetup { paper, size, custom_scale, duplex }))
}
#[cfg(test)]
mod tests {
use super::*;
fn setup(paper: usize, size: SizeMode, duplex: Option<Duplex>) -> PrinterSetup {
PrinterSetup { paper, size, custom_scale: 62.5, duplex }
}
#[test]
fn setups_round_trip_through_the_settings_file() {
let mut setups = Vec::new();
record(&mut setups, "Office", setup(4, SizeMode::Fit, Some(Duplex::LongEdge)));
record(&mut setups, "Label", setup(super::super::print::LABEL_PAPER, SizeMode::Actual, Some(Duplex::Off)));
record(&mut setups, "Plotter", setup(0, SizeMode::Custom(62.5), None));
record(&mut setups, "Shrinker", setup(1, SizeMode::Shrink, Some(Duplex::ShortEdge)));
assert_eq!(from_json(&to_json(&setups)), Some(setups));
}
#[test]
fn printing_again_moves_a_printer_first_and_the_oldest_drops_off() {
let mut setups = Vec::new();
for i in 0..MAX_PRINTERS + 3 {
record(&mut setups, &format!("Queue {i}"), setup(0, SizeMode::Fit, None));
}
assert_eq!(setups.len(), MAX_PRINTERS);
assert_eq!(setups[0].0, format!("Queue {}", MAX_PRINTERS + 2));
assert!(setups.iter().all(|(name, _)| name != "Queue 0"));
record(&mut setups, "Queue 10", setup(4, SizeMode::Actual, None));
assert_eq!(setups.len(), MAX_PRINTERS);
assert_eq!(setups[0], ("Queue 10".to_string(), setup(4, SizeMode::Actual, None)));
assert_eq!(setups.iter().filter(|(name, _)| name == "Queue 10").count(), 1);
}
#[test]
fn malformed_entries_are_skipped_whole() {
let good = serde_json::json!({"printer": "Label", "paper": "A4", "size": "actual", "custom_scale": 100.0, "duplex": "off"});
let mut bad = Vec::new();
for (key, value) in [
("printer", serde_json::json!("")),
("printer", serde_json::json!(5)),
("printer", serde_json::json!("x".repeat(MAX_NAME_BYTES + 1))),
("paper", serde_json::json!("a4")),
("paper", serde_json::json!(4)),
("size", serde_json::json!("fit-to-page")),
("size", serde_json::Value::Null),
("custom_scale", serde_json::json!(0.5)),
("custom_scale", serde_json::json!(1e9)),
("custom_scale", serde_json::json!("100")),
("duplex", serde_json::json!("Long-Edge")),
("duplex", serde_json::json!(true)),
] {
let mut entry = good.clone();
entry[key] = value;
bad.push(entry);
}
let mut missing = good.clone();
missing.as_object_mut().unwrap().remove("duplex");
bad.push(missing);
bad.push(serde_json::json!("Label"));
bad.push(serde_json::Value::Null);
for entry in &bad {
assert_eq!(from_json(&serde_json::json!([entry])), Some(Vec::new()), "{entry}");
}
// The good entry still reads among bad ones, once.
let mut list = bad.clone();
list.push(good.clone());
list.push(serde_json::json!({"printer": "Label", "paper": "A3", "size": "fit", "custom_scale": 100.0, "duplex": "printer"}));
let read = from_json(&serde_json::Value::Array(list)).unwrap();
assert_eq!(read.len(), 1);
assert_eq!(read[0].0, "Label");
assert_eq!(read[0].1.size, SizeMode::Actual);
// Not a list at all: the caller keeps what it has.
for v in [serde_json::Value::Null, serde_json::json!({}), serde_json::json!("Label"), serde_json::json!(3)] {
assert_eq!(from_json(&v), None, "{v}");
}
// A file listing more printers than are kept reads the first ones.
let many: Vec<_> = (0..MAX_PRINTERS * 2)
.map(|i| serde_json::json!({"printer": format!("Q{i}"), "paper": "A4", "size": "fit", "custom_scale": 100.0, "duplex": "printer"}))
.collect();
assert_eq!(from_json(&serde_json::Value::Array(many)).unwrap().len(), MAX_PRINTERS);
}
}
+113
View File
@@ -1115,3 +1115,116 @@ fn print_preferences_that_dont_parse_keep_the_defaults() {
let d = open(Some(r#"{"print": {"duplex": "off"}}"#)).print_draft;
assert_eq!(d.duplex, Some(Duplex::Off));
}
/// #2001: each printer keeps the paper, sizing and two-sided choice it last printed with, across
/// a restart, and the printer printed to last is chosen again. A label printer keeps 4 × 6 in
/// at Actual size, single-sided, while the office printer keeps A4 at Fit, two-sided.
#[test]
fn each_printer_keeps_what_it_last_printed_with_across_a_restart() {
use pdfcraft_engine::print::spool::{Duplex, Job, Printer};
use pdfcraft_engine::print::{LABEL_PAPER, PAPERS, SizeMode};
use std::sync::{Arc, Mutex};
let a4 = PAPERS.iter().position(|(name, _)| *name == "A4").unwrap();
let listed = Arc::new(Mutex::new(vec!["Office", "D520BT_Z"]));
let jobs: Arc<Mutex<Vec<Job>>> = Arc::default();
let attach = |h: &mut Harness<'static, PdfCraftApp>| {
let source = listed.clone();
h.state_mut().printer_source = Some(Arc::new(move || {
let names = source.lock().map(|n| n.clone()).unwrap_or_default();
names
.into_iter()
.map(|name| Printer { name: name.into(), default: name == "Office", two_sided: None, description: String::new() })
.collect()
}));
let sent = jobs.clone();
h.state_mut().spooler = Some(Arc::new(move |_pdf: &[u8], job: &Job| {
sent.lock().unwrap().push(job.clone());
Ok(String::new())
}));
};
let open = |h: &mut Harness<'static, PdfCraftApp>| {
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 {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(!h.state().print_draft.printers_loading);
h.run_steps(2);
};
let print = |h: &mut Harness<'static, PdfCraftApp>| -> Job {
let before = jobs.lock().unwrap().len();
h.get_by(|n| n.label().as_deref() == Some("Print") && n.role() == Role::Button).click();
for _ in 0..400 {
h.run_steps(1);
if jobs.lock().unwrap().len() > before {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
jobs.lock().unwrap().last().cloned().expect("the job reached the spooler")
};
let setup = |h: &Harness<'static, PdfCraftApp>| {
let d = &h.state().print_draft;
(d.printer.clone(), d.paper, d.size, d.duplex)
};
let label = (Some("D520BT_Z".to_string()), LABEL_PAPER, SizeMode::Actual, Some(Duplex::Off));
let office = (Some("Office".to_string()), a4, SizeMode::Fit, Some(Duplex::LongEdge));
let mut h = harness();
attach(&mut h);
open(&mut h);
assert_eq!(h.state().print_draft.printer.as_deref(), Some("Office"), "the system default first");
{
let d = &mut h.state_mut().print_draft;
d.printer = Some("D520BT_Z".into());
d.printer_picked = true;
d.paper = LABEL_PAPER;
d.size = SizeMode::Actual;
d.duplex = Some(Duplex::Off);
}
h.run_steps(2);
assert_eq!(print(&mut h).printer.as_deref(), Some("D520BT_Z"));
open(&mut h);
{
let d = &mut h.state_mut().print_draft;
d.printer = Some("Office".into());
d.paper = a4;
d.size = SizeMode::Fit;
d.duplex = Some(Duplex::LongEdge);
}
h.run_steps(2);
assert_eq!(print(&mut h).printer.as_deref(), Some("Office"));
// Choosing the label printer in the dialog brings back what it printed with.
open(&mut h);
assert_eq!(setup(&h), office, "the same printer keeps this session's choices");
h.get_by_value("Office").click();
h.run_steps(2);
h.get_by_label("D520BT_Z").click();
h.run_steps(2);
assert_eq!(setup(&h), label);
let job = print(&mut h);
assert_eq!(job.duplex, Some(Duplex::Off));
// After a restart the label printer, printed to last, is chosen again with its setup, not
// the system default; choosing the office printer brings back A4, Fit and two-sided.
let saved = h.state().persist();
let mut r = harness_with_settings(Some(saved.clone()));
attach(&mut r);
open(&mut r);
assert_eq!(setup(&r), label, "the label printer and its 4 × 6 in at Actual size, single-sided");
r.get_by_value("D520BT_Z").click();
r.run_steps(2);
r.get_by_label("Office (default)").click();
r.run_steps(2);
assert_eq!(setup(&r), office);
// A remembered printer that is gone falls back to the system default, with its own setup.
*listed.lock().unwrap() = vec!["Office"];
let mut gone = harness_with_settings(Some(saved));
attach(&mut gone);
open(&mut gone);
assert_eq!(setup(&gone), office);
}
+2 -2
View File
@@ -8189,8 +8189,8 @@ milestone = "M10"
status = "shipped"
commands = ["print.dialog"]
tools = ["doc_print", "printers"]
evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/workflows/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/workflows/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page", "crates/print/src/tests.rs::every_paper_reaches_the_spooler_by_a_name_of_its_size", "crates/automation/src/printing.rs::printing_to_a_printer_feeds_the_laid_out_paper", "crates/ui-egui/tests/it/print.rs::print_preferences_survive_a_restart_but_the_jobs_own_choices_reset"]
notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name. Two-sided (including the printer's own default), grayscale, collate, page sizing and Comments & forms are remembered across restarts (#2001); copies, pages, layout and orientation start from the defaults each time the dialog opens (ISO 32000-2 Table 147 initialises them per document). CUPS jobs from the dialog and doc_print ask for the chosen paper (media) in every size: PPD keywords for the common ones, PWG 5101.1 names for A6, A0–A2, ARCH and ANSI C–E, checked against a synthetic PPD with cupsfilter, not on a physical large-format printer (#3752). On macOS jobs still take the PPD's default paper tray until craft-print sends Auto Select (#3752). Not yet: the document's own NumCopies, PrintPageRange, Duplex and PrintScaling viewer preferences as the dialog's initial values."
evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::each_printer_keeps_what_it_last_printed_with_across_a_restart", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/workflows/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/workflows/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page", "crates/print/src/tests.rs::every_paper_reaches_the_spooler_by_a_name_of_its_size", "crates/automation/src/printing.rs::printing_to_a_printer_feeds_the_laid_out_paper", "crates/ui-egui/tests/it/print.rs::print_preferences_survive_a_restart_but_the_jobs_own_choices_reset"]
notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name. Two-sided (including the printer's own default), grayscale, collate, page sizing and Comments & forms are remembered across restarts (#2001); copies, pages, layout and orientation start from the defaults each time the dialog opens (ISO 32000-2 Table 147 initialises them per document). CUPS jobs from the dialog and doc_print ask for the chosen paper (media) in every size: PPD keywords for the common ones, PWG 5101.1 names for A6, A0–A2, ARCH and ANSI C–E, checked against a synthetic PPD with cupsfilter, not on a physical large-format printer (#3752). On macOS jobs still take the PPD's default paper tray until craft-print sends Auto Select (#3752). Not yet: the document's own NumCopies, PrintPageRange, Duplex and PrintScaling viewer preferences as the dialog's initial values. Each printer printed to also keeps its own paper, page sizing (Fit, Actual size, Shrink, Custom %) and two-sided choice across restarts, brought back when it is chosen, and the printer printed to last is preselected while it is listed, else the system default (at most 32 printers)."
[[feature]]
id = "print.printer-selection"