diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 2f1cf5e136..8a6f825e6a 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -38,8 +38,14 @@ fn bad(m: impl Into) -> ToolError { } /// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS` -/// index and size; US Letter when none is given. +/// 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(), @@ -51,7 +57,7 @@ fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { }; found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| { bad(format!( - "unknown paper {:?}; choose one of {}", + "unknown paper {:?}; choose one of {}, or a size such as 8.5x14in or 210x297mm (1 to 200 inches a side)", name.unwrap_or_default(), PAPERS.iter().map(|(name, _)| *name).collect::>().join(", ") )) @@ -237,6 +243,19 @@ impl Automation { } } +/// "WxH" with a unit (in, mm, cm or pt) as a portrait paper size in points; each side 1 to 200 +/// inches (PDF's own page limit). +fn paper_size(spec: &str) -> Option<(f64, f64)> { + let s = spec.trim().to_ascii_lowercase(); + let (body, per_point) = [("in", 1.0 / 72.0), ("mm", 25.4 / 72.0), ("cm", 2.54 / 72.0), ("pt", 1.0)] + .into_iter() + .find_map(|(unit, per_point)| s.strip_suffix(unit).map(|b| (b, per_point)))?; + let (w, h) = body.split_once('x')?; + let side = |t: &str| t.trim().parse::().ok().map(|v| v / per_point).filter(|v| (72.0..=14_400.0).contains(v)); + let (w, h) = (side(w)?, side(h)?); + Some((w.min(h), w.max(h))) +} + #[cfg(test)] mod tests { use super::*; @@ -289,7 +308,19 @@ mod tests { 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).ok().map(|j| j.printer), Some(None)); assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err()); } + + #[test] + fn a_paper_size_reads_its_numbers_and_needs_a_unit() { + assert_eq!(paper_size("8.5 x 14 in"), Some((612.0, 1008.0))); + assert_eq!(paper_size("1e3x1e3pt"), Some((1000.0, 1000.0))); + for spec in ["0x0mm", "-5x10cm", "5x-10cm", "10x15"] { + assert_eq!(paper_size(spec), None, "{spec}"); + } + } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 59fd154c14..680759e035 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -741,7 +741,7 @@ pub fn tools() -> Vec { "cut_marks": { "type": "boolean" }, "orientation": { "type": "string", "enum": ["auto", "portrait", "landscape"] }, "comments_forms": { "type": "string", "enum": ["document", "document-and-markups", "document-and-stamps", "form-fields-only"] }, - "paper": { "type": "string" }, + "paper": { "type": "string", "description": "A paper name (US Letter, A4…) or a size of its own such as 8.5x14in, 210x297mm, 10x15cm or 612x792pt (1 to 200 inches a side)." }, "path": { "type": "string" }, "printer": { "type": "string" }, "copies": { "type": "integer", "minimum": 1, "maximum": 999 }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 8758a6b0b4..4889ebfecf 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -10651,6 +10651,32 @@ fn a_missing_path_under_the_root_names_where_it_was_looked_for() { let _ = std::fs::remove_dir_all(base); } +/// `doc_print` takes a paper size of its own besides the named ones, in inches, millimetres, +/// centimetres or points, either way round; a size it can't read or outside 1 to 200 inches is +/// refused. +#[test] +fn doc_print_takes_a_paper_size_of_its_own() { + let dir = workdir("print-paper-size"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (paper, want) in + [("8.5x14in", (612.0, 1008.0)), ("297x210mm", (595.28, 841.89)), ("10 x 15 cm", (283.46, 425.2)), ("288x432PT", (288.0, 432.0))] + { + let file = format!("{}.pdf", want.0); + ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "paper": paper, "orientation": "portrait", "path": file })); + let bytes = std::fs::read(dir.join(&file)).unwrap(); + let out = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + let page = pdfcraft_model::pages(&out)[0].display_size(&out); + assert!((page.0 - want.0).abs() < 0.5 && (page.1 - want.1).abs() < 0.5, "{paper}: {page:?}"); + } + for paper in ["8.5xbroad", "8.5x14", "0.5x11in", "8.5x300in", "-8.5x11in", "NaNx11in"] { + match a.call("doc_print", &json!({ "doc": doc, "paper": paper, "path": "x.pdf" })) { + Err(ToolError::InvalidArgs(m)) => assert!(m.contains("unknown paper"), "{paper}: {m}"), + other => panic!("{paper}: {other:?}"), + } + } +} + /// `doc_print` prints as image: pictures at `dpi`, grey with `grayscale`; a resolution outside /// 72–600 is refused. #[test] diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index aa40ea0d24..525bf0ae3f 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -160,6 +160,9 @@ impl PdfCraftApp { v["pinned_tools"] = serde_json::json!(self.pinned_tools); // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); + // The Print dialog's paper list and each printer's paper (print.page-setup). + v["print_custom_papers"] = serde_json::json!(self.print_draft.custom_papers); + v["print_paper_by_printer"] = serde_json::json!(self.print_draft.remembered_paper); v["show_page_edge_shadows"] = self.show_page_edge_shadows.into(); // Not `check_updates_at_start`: that key was an opt-in of its own, off unless chosen, and // settings written then would turn this off unasked. @@ -168,8 +171,10 @@ impl PdfCraftApp { v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned); v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); v["organize_zoom"] = serde_json::json!(self.organize_zoom); - // Canonical names survive changes to the order of PAPERS and do not depend on UI language. - v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name)); + // Canonical names survive changes to the order of PAPERS and do not depend on UI language; + // 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())); v.to_string() } @@ -286,11 +291,16 @@ impl PdfCraftApp { if let Some(on) = v["always_default_view"].as_bool() { self.always_default_view = on; } - if let Some(paper) = v - .get("print_paper") - .and_then(serde_json::Value::as_str) - .and_then(|name| pdfcraft_engine::print::PAPERS.iter().position(|(canonical, _)| *canonical == name)) - { + // The Print dialog's paper list and each printer's paper; settings are untrusted, so each + // custom paper is checked as if it were typed. + if v.get("print_custom_papers").is_some() { + self.print_draft.custom_papers = print_paper::read_custom(&v["print_custom_papers"]); + } + if v.get("print_paper_by_printer").is_some() { + self.print_draft.remembered_paper = print_paper::read_remembered(&v["print_paper_by_printer"]); + } + // 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(on) = v["highlight_fields"].as_bool() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a06ffac94d..ced03c3a48 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -196,6 +196,7 @@ use workflows::ocr_verify; #[cfg(target_arch = "wasm32")] use workflows::ocr_web; use workflows::optimize_ui; +use workflows::print_paper; use workflows::print_ui; use workflows::scan_clean_ui; use workflows::web_print; diff --git a/crates/ui-egui/src/workflows.rs b/crates/ui-egui/src/workflows.rs index 7b1bbeb9f3..f20be70394 100644 --- a/crates/ui-egui/src/workflows.rs +++ b/crates/ui-egui/src/workflows.rs @@ -15,6 +15,7 @@ pub(super) mod ocr_verify; #[cfg(target_arch = "wasm32")] pub(super) mod ocr_web; pub(super) mod optimize_ui; +pub(super) mod print_paper; pub(super) mod print_ui; pub(super) mod scan_clean_ui; pub(super) mod web_print; diff --git a/crates/ui-egui/src/workflows/print_paper.rs b/crates/ui-egui/src/workflows/print_paper.rs new file mode 100644 index 0000000000..30edb4dc91 --- /dev/null +++ b/crates/ui-egui/src/workflows/print_paper.rs @@ -0,0 +1,224 @@ +//! The Print dialog's paper list and the paper each printer is remembered with (print.page-setup): +//! paper sizes of a person's own, added with Custom… beside Paper and kept with the settings, and +//! for each printer the paper chosen when "Remember this paper for …" was ticked at Print time. +//! Two-sided, grayscale, collate, sizing and comments & forms are left to the dialog itself. + +use std::collections::BTreeMap; + +use pdfcraft_engine::print::PAPERS; +use serde::{Deserialize, Serialize}; + +use crate::print_ui::PrintDraft; + +/// The largest side a custom paper can have, in points: PDF's own page limit (200 inches). +pub const MAX_SIDE: f64 = 14_400.0; +/// The smallest side, in points (one inch). +pub const MIN_SIDE: f64 = 72.0; +/// How many custom papers the settings keep. +pub const MAX_CUSTOM: usize = 100; +/// The longest name a custom paper keeps, in characters. +const MAX_NAME: usize = 64; + +/// A paper size added in the Print dialog (points, portrait). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CustomPaper { + pub name: String, + pub width: f64, + pub height: f64, +} + +impl CustomPaper { + /// A custom paper from a name and two sides in points, portrait; None when the name is empty + /// or a side is outside [`MIN_SIDE`]..=[`MAX_SIDE`], or a standard paper already has the name + /// (any case), which would otherwise be found first when the paper is looked up by name. + pub fn new(name: &str, a: f64, b: f64) -> Option { + let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect(); + let fits = |v: f64| v.is_finite() && (MIN_SIDE..=MAX_SIDE).contains(&v); + let standard = PAPERS.iter().any(|(n, _)| n.eq_ignore_ascii_case(&name)); + (!name.is_empty() && !standard && fits(a) && fits(b)).then(|| Self { name, width: a.min(b), height: a.max(b) }) + } +} + +/// The paper remembered for each printer, by the printer's name ("" is Save as PDF). +pub type RememberedPaper = BTreeMap; + +/// Custom papers read back from the settings, which are untrusted: each one is checked as if it +/// were typed, a name already taken keeps its first size, and at most [`MAX_CUSTOM`] are kept. +pub fn read_custom(value: &serde_json::Value) -> Vec { + let Some(list) = value.as_array() else { return Vec::new() }; + let mut out: Vec = Vec::new(); + for item in list { + let (Some(name), Some(w), Some(h)) = (item["name"].as_str(), item["width"].as_f64(), item["height"].as_f64()) else { continue }; + if let Some(paper) = CustomPaper::new(name, w, h) + && !out.iter().any(|p| p.name == paper.name) + && out.len() < MAX_CUSTOM + { + out.push(paper); + } + } + out +} + +/// The remembered papers read back from the settings: printer name to paper name, strings only. +pub fn read_remembered(value: &serde_json::Value) -> RememberedPaper { + let Some(map) = value.as_object() else { return RememberedPaper::new() }; + map.iter().filter_map(|(printer, paper)| paper.as_str().map(|p| (printer.clone(), p.to_string()))).collect() +} + +impl PrintDraft { + /// The paper list: the standard sizes, then the custom ones. + pub fn paper_count(&self) -> usize { + 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, + Some(c) => self.custom_papers.get(c).map_or(PAPERS[0].1, |p| (p.width, p.height)), + } + } + + /// The chosen paper's name (a standard name, or the custom paper's). + pub fn paper_name(&self) -> String { + match self.paper.checked_sub(PAPERS.len()) { + None => PAPERS[self.paper.min(PAPERS.len() - 1)].0.to_string(), + 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 { + PAPERS.iter().position(|(n, _)| *n == name).or_else(|| self.custom_papers.iter().position(|p| p.name == name).map(|i| 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() + } + + /// Add (or replace, by name) a custom paper and choose it. + pub fn add_custom_paper(&mut self, paper: CustomPaper) { + self.custom_papers.retain(|p| p.name != paper.name); + if self.custom_papers.len() >= MAX_CUSTOM { + self.custom_papers.remove(0); + } + self.custom_papers.push(paper); + self.paper = 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()) + && i < self.custom_papers.len() + { + self.custom_papers.remove(i); + self.paper = 0; + } + } + + /// The key the printer's paper is remembered under ("" is Save as PDF). + fn printer_key(&self) -> String { + self.printer.clone().unwrap_or_default() + } + + /// Start from the paper remembered for the printer chosen, if one was and it is still on the + /// list; the Remember box says whether one was. + pub fn apply_remembered(&mut self) { + let remembered = self.remembered_paper.get(&self.printer_key()).cloned(); + self.remember_paper = remembered.is_some(); + if let Some(i) = remembered.and_then(|name| self.paper_index(&name)) { + self.paper = i; + } + } + + /// At Print time: keep the paper for the printer when the box is ticked, forget it when not. + pub fn remember_now(&mut self) { + let key = self.printer_key(); + if self.remember_paper { + let name = self.paper_name(); + self.remembered_paper.insert(key, name); + } else { + self.remembered_paper.remove(&key); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_custom_paper_is_portrait_named_and_within_pdfs_limits() { + let p = CustomPaper::new(" Legal, tray 2 ", 1008.0, 612.0).unwrap(); + assert_eq!((p.name.as_str(), p.width, p.height), ("Legal, tray 2", 612.0, 1008.0)); + assert!(CustomPaper::new("", 612.0, 792.0).is_none()); + assert!(CustomPaper::new("\u{1}", 612.0, 792.0).is_none()); + assert!(CustomPaper::new("Tiny", 10.0, 792.0).is_none()); + assert!(CustomPaper::new("Huge", 612.0, 20_000.0).is_none()); + assert!(CustomPaper::new("NaN", f64::NAN, 792.0).is_none()); + assert_eq!(CustomPaper::new(&"x".repeat(500), 612.0, 792.0).unwrap().name.chars().count(), MAX_NAME); + } + + #[test] + fn settings_are_read_as_untrusted() { + let v = serde_json::json!([ + { "name": "Label", "width": 288.0, "height": 432.0 }, + { "name": "Label", "width": 300.0, "height": 400.0 }, + { "name": "Bad", "width": -1.0, "height": 432.0 }, + { "name": 5, "width": 288.0, "height": 432.0 }, + "nonsense", + ]); + assert_eq!(read_custom(&v), vec![CustomPaper { name: "Label".into(), width: 288.0, height: 432.0 }]); + assert!(read_custom(&serde_json::json!(5)).is_empty()); + let many: Vec<_> = (0..300).map(|i| serde_json::json!({ "name": format!("P{i}"), "width": 300.0, "height": 400.0 })).collect(); + assert_eq!(read_custom(&serde_json::Value::Array(many)).len(), MAX_CUSTOM); + let r = read_remembered(&serde_json::json!({ "Office": "A4", "": "Label", "Odd": 3 })); + assert_eq!(r.len(), 2); + assert_eq!(r.get("").map(String::as_str), Some("Label")); + assert!(read_remembered(&serde_json::json!([1])).is_empty()); + } + + #[test] + fn the_paper_list_is_the_standard_sizes_then_the_custom_ones() { + let mut d = PrintDraft::default(); + assert_eq!(d.paper_count(), PAPERS.len()); + d.add_custom_paper(CustomPaper::new("Label", 288.0, 432.0).unwrap()); + assert_eq!((d.paper, d.paper_name().as_str(), d.paper_size()), (PAPERS.len(), "Label", (288.0, 432.0))); + assert_eq!(d.paper_index("A4"), PAPERS.iter().position(|(n, _)| *n == "A4")); + assert_eq!(d.paper_index("Label"), Some(PAPERS.len())); + // The same name replaces the size; removing goes back to the first standard size. + d.add_custom_paper(CustomPaper::new("Label", 300.0, 400.0).unwrap()); + assert_eq!((d.paper_count(), d.paper_size()), (PAPERS.len() + 1, (300.0, 400.0))); + d.remove_custom_paper(); + assert_eq!((d.paper_count(), d.paper), (PAPERS.len(), 0)); + // A stale index reads as the first size, never out of bounds. + d.paper = PAPERS.len() + 7; + assert_eq!(d.paper_size(), PAPERS[0].1); + } + + #[test] + fn the_paper_is_remembered_per_printer_only_when_asked() { + let mut d = PrintDraft::default(); + let a4 = d.paper_index("A4").unwrap(); + d.printer = Some("Office".into()); + d.paper = a4; + d.remember_paper = true; + d.remember_now(); + d.printer = Some("Plotter".into()); + d.paper = 0; + d.apply_remembered(); + assert_eq!((d.paper, d.remember_paper), (0, false), "another printer keeps the dialog's paper"); + d.printer = Some("Office".into()); + d.apply_remembered(); + assert_eq!((d.paper, d.remember_paper), (a4, true)); + d.remember_paper = false; + d.remember_now(); + assert!(d.remembered_paper.is_empty()); + // A remembered custom paper that was removed since leaves the paper alone. + d.remembered_paper.insert("Office".into(), "Gone".into()); + d.paper = 0; + d.apply_remembered(); + assert_eq!((d.paper, d.remember_paper), (0, true)); + } +} diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 7ea7a18602..880dccbd00 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -171,8 +171,10 @@ impl PdfCraftApp { sheet: 0, selected, which, + custom_new: None, ..keep }; + self.print_draft.apply_remembered(); self.dialog = Some(crate::Dialog::Print); } @@ -314,7 +316,13 @@ 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); + // Only a different printer starts over with its remembered paper; the + // same one keeps the paper and the Remember box as they were left. + let default = default_printer(&printers); + if default != d.printer { + d.printer = default; + d.apply_remembered(); + } } d.printers = printers; d.printers_loading = false; diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 690dc3142e..d38272a001 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -158,6 +158,7 @@ pub(crate) fn body( d.printer_picked = true; d.show_driver_options = false; d.driver_error = None; + d.apply_remembered(); } ui.end_row(); // The spooler answered with no printer at all (#160): say so, rather than @@ -202,15 +203,35 @@ pub(crate) fn body( ui.end_row(); } let paper_label = ui.label(tl!("Paper:")); - let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); + d.paper = d.paper.min(d.paper_count().saturating_sub(1)); let before = d.paper; - craft_ui::ComboBox::from_id_salt("paper").selected_text(tl!(paper_name)).width(260.0).show_ui(ui, |ui| { - for (i, (name, _)) in PAPERS.iter().enumerate() { - ui.selectable_value(&mut d.paper, i, tl!(name)); + 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)); + } + 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); + } + }) + .response + .labelled_by(paper_label.id); + 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, + None => Some((String::new(), String::new(), String::new())), + }; } - }) - .response - .labelled_by(paper_label.id); + 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(); + } + }); // Label stock gets the label layout (cut, turned upright, fitted). if d.paper != before && d.paper == print::LABEL_PAPER { d.paper_before_label = Some(before); @@ -218,6 +239,11 @@ pub(crate) fn body( d.sheet = 0; } ui.end_row(); + if d.custom_new.is_some() { + ui.label(""); + custom_paper_row(ui, d, t); + ui.end_row(); + } }); // Windows says which drivers can't print two-sided as they're asked; they keep their // own setting, even when Two-sided is Off. @@ -559,6 +585,11 @@ pub(crate) fn body( }); ui.add_space(12.0); let (mut go, mut cancel) = (false, false); + // The paper is kept for this printer (or Save as PDF) when this is ticked at Print time. On its + // own row above the buttons: sharing their row moved them while the dialog settled. + let for_whom = d.printer.as_deref().map_or_else(|| tl!("Save as PDF").to_string(), printer_label); + ui.add(widgets::checkbox(&mut d.remember_paper, crate::i18n::fmt(tl!("Remember this paper for {printer}"), &[("printer", &for_whom)]))); + ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let looking = still_looking(d); let label = if d.printer.is_some() || looking { tl!("Print") } else { tl!("Save as PDF") }; @@ -569,5 +600,35 @@ pub(crate) fn body( cancel = true; } }); + if go { + d.remember_now(); + } (go, cancel) } + +/// The row that adds a custom paper: name, width and height in the preferences' unit. +fn custom_paper_row(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { + let unit = d.units; + let Some((name, width, height)) = d.custom_new.as_mut() else { return }; + let side = |s: &str| s.trim().replace(',', ".").parse::().ok().map(|v| v / unit.per_point()); + let paper = match (side(width), side(height)) { + (Some(w), Some(h)) => crate::print_paper::CustomPaper::new(name, w, h), + _ => None, + }; + let (mut add, mut cancel) = (false, false); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(name).hint_text(tl!("Name")).desired_width(110.0)).accessible_name(tl!("Name")); + ui.add(egui::TextEdit::singleline(width).hint_text(tl!("Width")).desired_width(52.0)).accessible_name(tl!("Width")); + ui.label("×"); + ui.add(egui::TextEdit::singleline(height).hint_text(tl!("Height")).desired_width(52.0)).accessible_name(tl!("Height")); + ui.label(egui::RichText::new(unit.label()).color(t.text_muted)); + add = ui.add_enabled(paper.is_some(), egui::Button::new(tl!("Add"))).clicked(); + cancel = ui.button(tl!("Cancel")).clicked(); + }); + if add && let Some(paper) = paper { + d.add_custom_paper(paper); + d.custom_new = None; + } else if cancel { + d.custom_new = None; + } +} diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 27080a7329..988880a550 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -1,4 +1,4 @@ -use super::{Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, print, spool}; +use super::{Binding, BookletSubset, Content, Layout, Orientation, PageOrder, SizeMode, Subset, print, spool}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Which { @@ -62,7 +62,16 @@ pub struct PrintDraft { /// Print as image (Acrobat's Advanced Print Setup) at `dpi`. pub as_image: bool, pub dpi: f64, + /// Index into the paper list: the standard sizes, then `custom_papers`. pub paper: usize, + /// Paper sizes added with Custom…, kept with the settings. + pub custom_papers: Vec, + /// The paper each printer is remembered with, kept with the settings. + pub remembered_paper: crate::print_paper::RememberedPaper, + /// "Remember this paper for …", applied at Print time. + pub remember_paper: bool, + /// The custom paper being added: name, width and height as typed (the preferences' unit). + 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, /// The sheet shown in the preview (0-based). @@ -126,6 +135,10 @@ impl Default for PrintDraft { as_image: false, dpi: 300.0, paper: 0, + custom_papers: Vec::new(), + remembered_paper: Default::default(), + remember_paper: false, + custom_new: None, paper_before_label: None, sheet: 0, current_page: 0, @@ -206,7 +219,7 @@ impl PrintDraft { }; Ok(print::Settings { pages, - paper: PAPERS[self.paper.min(PAPERS.len() - 1)].1, + paper: self.paper_size(), orientation: self.orientation, layout, content: self.content, diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 972bc78b82..3396207d41 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -66,6 +66,8 @@ mod pointer; mod preferences_layout; mod prepare; mod print; +mod print_custom_paper_names; +mod print_late_printer_list; mod protect; mod reading_colors; mod recent_files; diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 3e0fec7a6e..a6346ca1e2 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -918,6 +918,98 @@ fn restored_paper_keeps_preview_and_saved_sheet_dimensions() { std::fs::remove_file(out).unwrap(); } +/// After the Print dialog opens: frames until its printer list has loaded (its arrival changes +/// the dialog's rows, even an empty list, which adds the "no printers" line), then a few more so +/// the dialog has settled where it stays. A click before then can land where a control was +/// (windows-latest CI). +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + for _ in 0..500 { + h.run_steps(1); + if !h.state().print_draft.printers_loading { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert!(!h.state().print_draft.printers_loading, "the printer list loaded"); + h.run_steps(8); +} + +/// Page setup: a paper size of one's own joins the Paper list (Custom…), the paper is kept for +/// the printer when "Remember this paper for …" is ticked at Print time, starts the dialog next +/// time, survives a restart with the settings, and is forgotten when unticked; Remove takes a +/// custom size off the list. +#[test] +fn custom_papers_and_the_paper_remembered_for_a_printer() { + let mut h = harness(); + // No printers here: Save as PDF is the dialog's printer, now and when it opens again. + h.state_mut().printer_source = Some(std::sync::Arc::new(Vec::new)); + assert!(h.state_mut().execute("print.dialog")); + settle(&mut h); + assert_eq!(h.state().print_draft.printer, None); + h.get_by_label("Custom…").click(); + h.run_steps(2); + // Typed in the preferences' unit: 4 × 6 inches. + let per_point = h.state().print_draft.units.per_point(); + h.state_mut().print_draft.custom_new = Some(("Label 4×6".into(), format!("{}", 432.0 * per_point), format!("{}", 288.0 * per_point))); + h.run_steps(2); + h.get_by_label("Add").click(); + h.run_steps(2); + { + let d = &h.state().print_draft; + assert!(d.paper_is_custom() && d.paper_name() == "Label 4×6", "{}", d.paper_name()); + let (w, l) = d.paper_size(); + 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. + h.run_steps(8); + h.get_by_label("Remember this paper for Save as PDF").click(); + h.run_steps(1); + assert!(h.state().print_draft.remember_paper, "ticked"); + let out = std::env::temp_dir().join(format!("pdfcraft-print-paper-test-{}.pdf", std::process::id())); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.run_steps(8); + h.get_by_label("Save as PDF").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + let bytes = std::fs::read(&out).expect("saved"); + let _ = std::fs::remove_file(&out); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + let sheet = pdfcraft_model::pages(&saved)[0].display_size(&saved); + assert!((sheet.0.min(sheet.1) - 288.0).abs() < 0.5 && (sheet.0.max(sheet.1) - 432.0).abs() < 0.5, "the label paper: {sheet:?}"); + assert_eq!(h.state().print_draft.remembered_paper.get("").map(String::as_str), Some("Label 4×6")); + // Next time the dialog starts with it. + h.state_mut().print_draft.paper = 0; + assert!(h.state_mut().execute("print.dialog")); + settle(&mut h); + assert_eq!((h.state().print_draft.paper_name().as_str(), h.state().print_draft.remember_paper), ("Label 4×6", true)); + // And after a restart. + let settings = h.state().persist(); + let mut fresh = PdfCraftApp::new(); + fresh.restore(&settings); + assert_eq!(fresh.print_draft.paper_count(), h.state().print_draft.paper_count()); + assert_eq!(fresh.print_draft.remembered_paper, h.state().print_draft.remembered_paper); + // Unticked at Print time, the paper is forgotten for Save as PDF. + h.get_by_label("Remember this paper for Save as PDF").click(); + h.run_steps(1); + assert!(!h.state().print_draft.remember_paper, "unticked"); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.run_steps(8); + h.get_by_label("Save as PDF").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None, "saved"); + let _ = std::fs::remove_file(&out); + assert!(h.state().print_draft.remembered_paper.is_empty()); + // Remove takes the custom size off the list. + assert!(h.state_mut().execute("print.dialog")); + settle(&mut h); + let custom = h.state().print_draft.paper_count() - 1; + h.state_mut().print_draft.paper = custom; + h.run_steps(2); + h.get_by_label("Remove").click(); + h.run_steps(2); + assert_eq!((h.state().print_draft.paper_count(), h.state().print_draft.paper), (custom, 0)); +} + /// #2707: a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided /// while Booklet is chosen): the job flips on the short edge for both sides and prints /// one-sided for the front or back only, and the Two-sided choice is kept for other layouts. diff --git a/crates/ui-egui/tests/it/print_custom_paper_names.rs b/crates/ui-egui/tests/it/print_custom_paper_names.rs new file mode 100644 index 0000000000..ba083b3c0b --- /dev/null +++ b/crates/ui-egui/tests/it/print_custom_paper_names.rs @@ -0,0 +1,62 @@ +//! A custom paper cannot take the name of a standard one: papers are looked up by name, so the +//! standard size would be found first and a remembered custom paper would come back as it. + +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use pdfcraft_ui_egui::PdfCraftApp; + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + app + }); + h.run_steps(4); + h +} + +#[test] +fn a_custom_paper_cannot_take_a_standard_name() { + let mut h = harness(); + // No printers here: the list is empty as soon as it is asked for. + h.state_mut().printer_source = Some(std::sync::Arc::new(Vec::new)); + assert!(h.state_mut().execute("print.dialog")); + for _ in 0..500 { + h.run_steps(1); + if !h.state().print_draft.printers_loading { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert!(!h.state().print_draft.printers_loading, "the printer list loaded"); + h.run_steps(8); + h.get_by_label("Custom…").click(); + h.run_steps(2); + let before = h.state().print_draft.paper_count(); + let per_point = h.state().print_draft.units.per_point(); + let (w, l) = (format!("{}", 288.0 * per_point), format!("{}", 432.0 * per_point)); + // The name of a standard paper, in either case, is refused: Add stays disabled. + for name in ["A4", " a4 "] { + h.state_mut().print_draft.custom_new = Some((name.into(), w.clone(), l.clone())); + h.run_steps(3); + assert!(h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled(), "{name:?} is a standard paper's name"); + assert_eq!(h.state().print_draft.paper_count(), before); + } + // Any other name is accepted by the same row. + h.state_mut().print_draft.custom_new = Some(("A4 label".into(), w.clone(), l.clone())); + h.run_steps(3); + assert!(!h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled()); + + // Settings read back are checked the same way: the custom "A4" is dropped, the rest kept. + let paper = |name: &str| serde_json::json!({ "name": name, "width": 288.0, "height": 432.0 }); + let settings = serde_json::json!({ + "print_custom_papers": [paper("A4"), paper("Label"), paper("us letter")], + "print_paper_by_printer": { "": "A4" }, + }); + let mut fresh = PdfCraftApp::new(); + fresh.restore(&settings.to_string()); + let names: Vec<&str> = fresh.print_draft.custom_papers.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Label"]); +} diff --git a/crates/ui-egui/tests/it/print_late_printer_list.rs b/crates/ui-egui/tests/it/print_late_printer_list.rs new file mode 100644 index 0000000000..b32772df20 --- /dev/null +++ b/crates/ui-egui/tests/it/print_late_printer_list.rs @@ -0,0 +1,66 @@ +//! A printer list that arrives late must not undo what was picked while it was being read, when +//! the printer it names is the one the dialog already has. + +use egui_kittest::Harness; +use pdfcraft_engine::print::spool::Printer; +use pdfcraft_ui_egui::PdfCraftApp; +use std::sync::{Arc, Mutex}; + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + app + }); + h.run_steps(4); + h +} + +fn wait_for_list(h: &mut Harness<'static, PdfCraftApp>) { + 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, "the printer list loaded"); +} + +#[test] +fn a_late_list_keeps_the_paper_picked_meanwhile() { + let mut h = harness(); + // The source waits for the test to let it answer, once the first list is in. + let gate = Arc::new(Mutex::new(())); + let wait = gate.clone(); + h.state_mut().printer_source = Some(Arc::new(move || { + let _open = wait.lock(); + vec![Printer { name: "Office".into(), default: true, two_sided: None, description: String::new() }] + })); + assert!(h.state_mut().execute("print.dialog")); + wait_for_list(&mut h); + h.run_steps(4); + assert_eq!(h.state().print_draft.printer.as_deref(), Some("Office")); + + // "Office" is remembered with Tabloid (the third size). + h.state_mut().print_draft.remembered_paper.insert("Office".into(), "Tabloid".into()); + h.state_mut().dialog = None; + let hold = gate.lock().unwrap(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(2); + assert!(h.state().print_draft.printers_loading, "the fresh list is still being read"); + assert_eq!(h.state().print_draft.printer.as_deref(), Some("Office"), "last time's list shows at once"); + assert_eq!((h.state().print_draft.paper_name().as_str(), h.state().print_draft.remember_paper), ("Tabloid", true)); + + // Meanwhile the person picks another paper and unticks Remember. + { + let d = &mut h.state_mut().print_draft; + d.paper = 0; + d.remember_paper = false; + } + drop(hold); + wait_for_list(&mut h); + h.run_steps(2); + let d = &h.state().print_draft; + assert_eq!(d.printer.as_deref(), Some("Office"), "the same printer"); + assert_eq!((d.paper, d.remember_paper), (0, false), "the paper and the Remember box are as they were left"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index b15cc78541..f8bfd29018 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8378,11 +8378,11 @@ title = "Page setup" area = "L" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["print.dialog"] +tools = ["doc_print"] +evidence = ["crates/ui-egui/tests/it/print.rs::custom_papers_and_the_paper_remembered_for_a_printer", "crates/ui-egui/src/workflows/print_paper.rs::the_paper_is_remembered_per_printer_only_when_asked", "crates/ui-egui/src/workflows/print_paper.rs::settings_are_read_as_untrusted", "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; 'Remember this paper for ' at Print time keeps the paper for that printer (or Save as PDF) and the dialog starts with it. doc_print takes sizes such as 8.5x14in or 210x297mm. Missing: margins, and the dialog's other choices (orientation, two-sided, sizing and so on) remembered per printer." [[feature]] id = "print.print-ready-pdf"