diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 9ddd3c04d9..26abaaa840 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "25a6a7ccbc113f8a64092b8962c9c0bfe64bfb6dc2000e2a6164881b492e8684" +sha256 = "d1f4c7c71d2359e816878e8de837119649513e20b8cf1036276f4d18639398f5" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "957b2ca722eb3ebb6c6303ff5e25245b850b3a3e6ddba094516174e804aa96c1" +sha256 = "1cac4789161502b2fc3c52ac0bc9a89fd714495a87c594ba7a4f04e49e5a6456" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4d139791454c1c3a00e2726e495b1170eb1f5be95f6cd5e12e7311c85aca0259" +sha256 = "85a57e66f819e93c8c541548cd932ec98cd3dca888c25b62d5c973fb795eb78a" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "716fb1f47bc69c355a688f116715b819a03c59d99ba9e03d65db2dd79bbe11b9" +sha256 = "f005a60c77110251be379572e3eb6bf9a7907946788f5ce0f8689800f5cc7b76" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f78e4175753553fcb27d61697e64ab092cede8433a8ae68ca1f553879b4217dc" +sha256 = "a53fb2b7d91a6f9cb37c5d7fcc77e0884b9b23a0f406569ba203062cff731be1" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "329b95966eca05438ea23deb2a48f61d9af9e984017d14942af06b3bb0799e4b" +sha256 = "affd4f7f2e7285abfb987625bbb726cf3d761f0a67ccaf00c18d986be3fa5f9d" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "50390d434fbebc5d6ed1a259db96d73d76be5678f4c87a8b04ddd1c38a4901f7" +sha256 = "88e402a8a16ba8ccd6f73e91326691ce4c4f86bf113cc484fb4c2af953ab3122" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4b7618b83b5b4479e5b8e4022a302689d8acf194a10368d7ef34302f8e19e299" +sha256 = "6c2860945882f12af506be1830182ddb6c783a524f718ee0c0540bf781400641" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "78053df6ec3de2d0b6a10338b57618302deb4ec8cfb794be5a4a4d2bdc2f950b" +sha256 = "af9bd7f0a2c9f64faaea928e219be50a9ce8a71d40ae4c13e84cfe1aed751f40" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "285d3d9c8fff269ceb032e6be67a1c33d596f2ed80c9d8677f5655fb014f43b5" +sha256 = "b93836c384d11e5027b902f6f5a9fa389a5a793213bb77f6754e4cf71fdc28c8" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "06e8584e6f16528598e311e1b0dbd167701967c6786e14701886285babb38c9f" +sha256 = "750c7229170e1ffd96b140ed13f0ce85a7c04a5223ed2648a75c96e2c0d65140" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/ROADMAP.md b/ROADMAP.md index dba0e114ba..3c149ccbb2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run - **2026-10-09 (M14, #577):** The desktop GPU setup enables the selected adapter's supported 2D texture extent instead of egui-wgpu's fixed 8192-pixel extent. This removes the artificial surface ceiling behind a mixed-DPI Windows startup crash when opening Outlook PDF attachments. Synthetic regressions cover the observed 8600 × 3423 surface, downlevel adapters and preservation of other device limits. Hardware resolution limits still apply; milestone and overall effort estimates are unchanged. +- **2026-10-09 (M5, Fill & Sign flatten on save):** Preferences and `doc_save` can bake Fill & Sign text, marks and signatures into the page when saving. The option defaults off. Other comments and form fields stay, a second flatten does not reuse an XObject name already on the page, and a document that cannot be changed is left unwritten. Overall estimate unchanged (about 30–35%). - **2026-10-09 (M5, Fill & Sign flatten on save):** Preferences and `doc_save` can bake Fill & Sign text, marks and signatures into the page when saving. The option defaults off. Other comments and form fields stay, a second flatten does not reuse an XObject name already on the page, and a document that cannot be changed is left unwritten. Overall estimate unchanged (about 30–35%). - **2026-10-09 (M5, Fill & Sign flatten on save):** Preferences and `doc_save` can bake Fill & Sign text, marks and signatures into the page when saving. The option defaults off. Other comments and form fields stay, a second flatten does not reuse an XObject name already on the page, and a document that cannot be changed is left unwritten. Overall estimate unchanged (about 30–35%). - **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged. @@ -148,6 +149,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run - **2026-10-09 (M4, page grid zoom):** The Organize page grid zooms from 50% to 300%: toolbar buttons with a percentage that resets, trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0. Pages drawn larger than their thumbnails are rendered at the size drawn while in view, so text stays sharp in documents of any length. Not yet: a slider, remembering the size, a size control for the Pages panel. Overall estimate unchanged (about 30–35%). +- **2026-10-09 (M4, page grid zoom):** The Organize page grid zooms from 50% to 300%: toolbar buttons with a percentage that resets, trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0. Pages drawn larger than their thumbnails are rendered at the size drawn while in view, so text stays sharp in documents of any length. Not yet: a slider, remembering the size, a size control for the Pages panel. Overall estimate unchanged (about 30–35%). + - **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged. - **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged. - **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%). diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index b3999c0789..bfbd90a934 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -516,6 +516,7 @@ impl Automation { "redact_clear" => self.redact_clear(&a)?, "doc_hidden_info" => self.doc_hidden_info(&a)?, "printers" => self.printers()?, + "printer_options" => self.printer_options(&a)?, "link_list" => self.link_list(&a)?, "link_add" => self.link_add(&a)?, "link_edit" => self.link_edit(&a)?, diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index a21fff0493..6ba8b393a6 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -16,6 +16,18 @@ impl Automation { Ok(json!({ "count": list.len(), "printers": list })) } + pub(crate) fn printer_options(&self, a: &Args) -> Result { + let printer = a.str("printer")?; + let list: Vec = spool::printer_options(printer) + .into_iter() + .map(|o| { + let choices: Vec = o.choices.iter().map(|(value, label)| json!({ "value": value, "label": label })).collect(); + json!({ "key": o.key, "label": o.label, "group": o.group, "default": o.default, "choices": choices }) + }) + .collect(); + Ok(json!({ "printer": printer, "count": list.len(), "options": list })) + } + pub(crate) fn doc_print(&mut self, a: &Args) -> Result { let doc = self.doc(a)?; let (id, count) = (doc.id, doc.info.pages.len()); @@ -122,6 +134,17 @@ impl Automation { }, grayscale: a.opt_bool("grayscale")?.unwrap_or(false), title: name, + options: match a.get("options") { + None => Vec::new(), + Some(Value::Object(m)) => m + .iter() + .map(|(k, v)| match v.as_str() { + Some(v) => Ok((k.clone(), v.to_string())), + None => Err(bad(format!("options.{k} must be a string (a choice from printer_options)"))), + }) + .collect::>>()?, + Some(_) => return Err(bad("options must be an object of option keys and choices from printer_options")), + }, }; out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 74eb2bbb62..5ca527006e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -395,10 +395,13 @@ pub fn tools() -> Vec { &["doc"], )), t("printers", "List printers", "The printers the system's print spooler knows (CUPS on macOS and Linux), with the default marked.").ro().with(schema(json!({}), &[])), + 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( "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; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, 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), 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/A3/A4/A5. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale.", + "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; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, 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), 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/A3/A4/A5. 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!({ @@ -425,6 +428,7 @@ pub fn tools() -> Vec { "collate": { "type": "boolean" }, "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge"] }, "grayscale": { "type": "boolean" }, + "options": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Printer driver options from printer_options: key → choice value." }, }), &["doc"], )), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index a3ba9976b0..cbda75f2b1 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1836,6 +1836,17 @@ fn printing_through_tools() { let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); assert!(ok(&mut a, "printers", json!({}))["printers"].is_array()); + let none = ok(&mut a, "printer_options", json!({ "printer": "No_Such_Queue_PdfCraft" })); + assert_eq!((none["count"].as_u64(), none["options"].as_array().map(Vec::len)), (Some(0), Some(0))); + assert!(matches!(a.call("printer_options", &json!({})), Err(ToolError::InvalidArgs(_)))); + assert!(matches!( + a.call("doc_print", &json!({ "doc": doc, "printer": "default", "options": ["InputSlot"] })), + Err(ToolError::InvalidArgs(m)) if m.contains("printer_options") + )); + assert!(matches!( + a.call("doc_print", &json!({ "doc": doc, "printer": "default", "options": { "InputSlot": 2 } })), + Err(ToolError::InvalidArgs(m)) if m.contains("options.InputSlot") + )); let n = ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]["pages"].as_u64().unwrap(); let r = ok(&mut a, "doc_print", json!({ "doc": doc, "layout": "multiple", "per_sheet": 4, "path": "sheets.pdf" })); assert_eq!(r["sheets"].as_u64(), Some(n.div_ceil(4))); diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index 176b5a2dbf..86ebd9c5ce 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -27,11 +27,14 @@ pub struct Job { pub duplex: Duplex, pub grayscale: bool, pub title: String, + /// The printer driver's own job options (a PPD keyword and the chosen value), such as the + /// paper tray or paper type; see [`printer_options`]. CUPS only. + pub options: Vec<(String, String)>, } impl Default for Job { fn default() -> Self { - Job { printer: None, copies: 1, collate: true, duplex: Duplex::Off, grayscale: false, title: "PdfCraft".into() } + Job { printer: None, copies: 1, collate: true, duplex: Duplex::Off, grayscale: false, title: "PdfCraft".into(), options: Vec::new() } } } @@ -65,9 +68,197 @@ pub fn lp_args(job: &Job) -> Vec { } // The sheets are already laid out at their final size. opt("fit-to-page=false"); + for (key, value) in &job.options { + // Driver options are PPD keywords; anything else is not something lp can be given. + if is_ppd_keyword(key) && is_ppd_keyword(value) && !HANDLED_OPTIONS.contains(&key.as_str()) { + a.extend(["-o".to_string(), format!("{key}={value}")]); + } + } a } +/// One of the printer driver's job options (a PPD `OpenUI` entry), such as the paper tray. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PrinterOption { + /// The PPD keyword (`InputSlot`). + pub key: String, + /// What the driver calls it (`Paper tray`); the keyword when it gives no name. + pub label: String, + /// The driver's group (`Paper Handling`); empty outside a group. + pub group: String, + /// The choices: keyword and label (`Tray2`, `Tray 2`). + pub choices: Vec<(String, String)>, + /// The choice the printer uses when a job doesn't say. + pub default: String, +} + +/// Options PdfCraft's Print dialog sets itself (paper, two-sided, collation), so the driver's +/// copies of them are neither shown nor sent. +pub const HANDLED_OPTIONS: &[&str] = &["PageSize", "PageRegion", "Duplex", "Collate", "ImageableArea", "PaperDimension"]; + +/// Caps on what a PPD may make the dialog hold (PPDs are text files from the printer vendor). +const MAX_OPTIONS: usize = 400; +const MAX_CHOICES: usize = 400; + +/// A PPD keyword: printable ASCII without spaces, `/`, `:` or `=`. +fn is_ppd_keyword(s: &str) -> bool { + !s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_graphic() && !matches!(b, b'/' | b':' | b'=' | b'"')) +} + +/// `Name/Label` → (`Name`, `Label`), the label falling back to the name. +fn name_label(s: &str) -> (&str, &str) { + match s.split_once('/') { + Some((n, l)) if !l.trim().is_empty() => (n.trim(), l.trim()), + Some((n, _)) => (n.trim(), n.trim()), + None => (s.trim(), s.trim()), + } +} + +/// The job options a PPD offers, in file order, without the installable-hardware group and the +/// options PdfCraft sets itself ([`HANDLED_OPTIONS`]). Lenient: malformed lines are skipped. +pub fn parse_ppd(ppd: &str) -> Vec { + let mut out: Vec = Vec::new(); + let (mut group, mut group_key) = (String::new(), String::new()); + let mut open: Option = None; + for line in ppd.lines() { + let line = line.trim_end_matches('\r'); + if let Some(rest) = line.strip_prefix("*OpenGroup:") { + let (k, l) = name_label(rest); + (group_key, group) = (k.to_string(), l.to_string()); + } else if line.starts_with("*CloseGroup") { + (group_key, group) = (String::new(), String::new()); + } else if let Some(rest) = line.strip_prefix("*OpenUI") { + let Some((head, _kind)) = rest.trim().rsplit_once(':') else { continue }; + let (key, label) = name_label(head.trim().trim_start_matches('*')); + open = is_ppd_keyword(key).then(|| PrinterOption { + key: key.to_string(), + label: label.to_string(), + group: group.clone(), + choices: Vec::new(), + default: String::new(), + }); + } else if line.starts_with("*CloseUI") { + if let Some(o) = open.take() + && !o.choices.is_empty() + && group_key != "InstallableOptions" + && !HANDLED_OPTIONS.contains(&o.key.as_str()) + && out.len() < MAX_OPTIONS + { + out.push(o); + } + } else if let Some(o) = open.as_mut() { + let Some(rest) = line.strip_prefix('*') else { continue }; + if let Some(value) = rest.strip_prefix("Default").and_then(|r| r.strip_prefix(o.key.as_str())).and_then(|r| r.strip_prefix(':')) { + o.default = value.trim().to_string(); + } else if let Some(rest) = rest.strip_prefix(o.key.as_str()).and_then(|r| r.strip_prefix(' ')) { + // `*InputSlot Tray2/Tray 2: "<< ... >>"`; the code after the colon is the driver's. + let head = rest.split_once(':').map_or(rest, |(h, _)| h); + let (choice, label) = name_label(head); + if is_ppd_keyword(choice) && o.choices.len() < MAX_CHOICES && o.choices.iter().all(|(c, _)| c != choice) { + o.choices.push((choice.to_string(), label.to_string())); + } + } + } + } + for o in &mut out { + if !o.choices.iter().any(|(c, _)| *c == o.default) { + o.default = o.choices.first().map(|(c, _)| c.clone()).unwrap_or_default(); + } + } + out +} + +/// The current choice of each option in `lpoptions -p NAME -l` output (`Key/Label: a *b c`): the +/// queue's defaults, including the user's own (`~/.cups/lpoptions`). +pub fn parse_lpoptions(out: &str) -> Vec<(String, String)> { + out.lines() + .filter_map(|l| { + let (head, choices) = l.split_once(':')?; + let (key, _) = name_label(head); + let current = choices.split_whitespace().find_map(|c| c.strip_prefix('*'))?; + Some((key.to_string(), current.to_string())) + }) + .collect() +} + +/// A PPD's text: UTF-8, or ISO Latin-1 (the PPD default) when it isn't valid UTF-8. +pub fn ppd_text(bytes: &[u8]) -> String { + match std::str::from_utf8(bytes) { + Ok(s) => s.to_string(), + Err(_) => bytes.iter().map(|&b| char::from(b)).collect(), + } +} + +/// The driver's job options for a printer (CUPS: its PPD, with the queue's current defaults); +/// empty when it has none or on other platforms. Reads files and runs `lpoptions`, so call it +/// when the user asks, not every frame. +pub fn printer_options(printer: &str) -> Vec { + #[cfg(all(unix, not(target_arch = "wasm32")))] + { + // Queue names never contain `/`; anything else is not a queue and not a path to follow. + if !is_ppd_keyword(printer) { + return Vec::new(); + } + const PPD_CAP: u64 = 16 << 20; + let ppd = std::fs::File::open(format!("/etc/cups/ppd/{printer}.ppd")).ok().and_then(|f| { + use std::io::Read as _; + let mut bytes = Vec::new(); + f.take(PPD_CAP).read_to_end(&mut bytes).ok().map(|_| bytes) + }); + let mut options = ppd.map(|b| parse_ppd(&ppd_text(&b))).unwrap_or_default(); + let current = std::process::Command::new("lpoptions") + .args(["-p", printer, "-l"]) + .output() + .map(|o| parse_lpoptions(&String::from_utf8_lossy(&o.stdout))) + .unwrap_or_default(); + for (key, value) in current { + if let Some(o) = options.iter_mut().find(|o| o.key == key) + && o.choices.iter().any(|(c, _)| *c == value) + { + o.default = value; + } + } + options + } + #[cfg(not(all(unix, not(target_arch = "wasm32"))))] + { + let _ = printer; + Vec::new() + } +} + +/// Whether [`open_printer_preferences`] can show the driver's own settings window (Windows). +pub const HAS_PRINTER_PREFERENCES: bool = cfg!(windows); + +/// Open the printer driver's Printing Preferences window (Windows: `printui.dll`). What the user +/// sets there is saved as that printer's defaults for this Windows user, which every job starts +/// from; the Print dialog's copies, two-sided, grayscale and paper still apply on top. +pub fn open_printer_preferences(printer: &str) -> Result<(), PrintError> { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // printui takes the name in quotes; Windows printer names can't contain one anyway. + if printer.is_empty() || printer.contains('"') { + return Err(PrintError::Spool("not a printer name".into())); + } + let root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()); + let rundll = std::path::Path::new(&root).join("System32").join("rundll32.exe"); + std::process::Command::new(rundll) + .raw_arg("printui.dll,PrintUIEntry") + .raw_arg("/e") + .raw_arg("/n") + .raw_arg(format!("\"{printer}\"")) + .spawn() + .map(|_| ()) + .map_err(|e| PrintError::Spool(format!("the printer's preferences could not be opened: {e}"))) + } + #[cfg(not(windows))] + { + let _ = printer; + Err(PrintError::Spool("printer preferences open only on Windows; use the printer's options instead".into())) + } +} + /// `lpstat -p -d`, forced to print untranslated messages so [`parse_lpstat`] can read them. /// /// `LC_ALL`/`LANG=C` is enough on Linux. macOS CUPS ignores them and follows the user's diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 101e3d4c25..cfdab70cc6 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -190,8 +190,15 @@ fn spooler_arguments_and_printer_list() { [spool::Printer { name: "Office_Laser".into(), default: true }, spool::Printer { name: "Label_Writer".into(), default: false }] ); assert!(parse_lpstat("lpstat: No destinations added.\nno system default destination\n").is_empty()); - let job = - Job { printer: Some("Office_Laser".into()), copies: 3, collate: false, duplex: Duplex::LongEdge, grayscale: true, title: "memo.pdf".into() }; + let job = Job { + printer: Some("Office_Laser".into()), + copies: 3, + collate: false, + duplex: Duplex::LongEdge, + grayscale: true, + title: "memo.pdf".into(), + options: Vec::new(), + }; assert_eq!( lp_args(&job).join(" "), "-d Office_Laser -n 3 -t memo.pdf -o collate=false -o sides=two-sided-long-edge -o print-color-mode=monochrome -o fit-to-page=false" @@ -359,3 +366,82 @@ fn the_spoolers_reply_drops_the_file_count_of_a_job_sent_on_stdin() { ); assert_eq!(spool::job_message(b""), ""); } + +/// A PPD shaped like a Fiery's: installable options, multi-line PostScript in the choices, Latin-1 +/// labels, options the Print dialog sets itself. +const FIERY_PPD: &[u8] = b"*PPD-Adobe: \"4.3\"\n\ +*LanguageEncoding: ISOLatin1\n\ +*OpenGroup: InstallableOptions/Installable Options\n\ +*OpenUI *EFFinisher/Finisher option: PickOne\n\ +*DefaultEFFinisher: False\n\ +*EFFinisher False/Not installed: \"\"\n\ +*EFFinisher SingleStapler/Single stapler: \"\"\n\ +*CloseUI: *EFFinisher\n\ +*CloseGroup: InstallableOptions\n\ +*OpenGroup: FPPaperSource/Media\n\ +*OpenUI *InputSlot/Paper tray: PickOne\n\ +*OrderDependency: 20.0 AnySetup *InputSlot\n\ +*DefaultInputSlot: AutoSelect\n\ +*InputSlot AutoSelect/Auto tray select: \"\n\ +userdict /XJXEFIsetpageproperties known\n\ +{ << /XJXsettrayselV2 [ 7 ] >> XJXEFIsetpageproperties } if\"\n\ +*End\n\ +*InputSlot ManualFeed/Bypass tray: \"\n\ +{ pop 2 XJXsettrayselV2 } if\"\n\ +*End\n\ +*InputSlot Tray2/Tray 2: \"\"\n\ +*fr.InputSlot Tray2/Bac 2: \"\"\n\ +*CloseUI: *InputSlot\n\ +*OpenUI *EFMediaType/Paper type: PickOne\n\ +*DefaultEFMediaType: Plain\n\ +*EFMediaType Plain/Plain: \"\"\n\ +*EFMediaType Heavy1/Thick 1 (106\xad163 g/m\xb2): \"\"\n\ +*CloseUI: *EFMediaType\n\ +*CloseGroup: FPPaperSource\n\ +*OpenUI *PageSize/Page size: PickOne\n\ +*DefaultPageSize: A4\n\ +*PageSize A4/A4: \"\"\n\ +*CloseUI: *PageSize\n\ +*OpenUI *EFRaster/Print queue action: PickOne\n\ +*DefaultEFRaster: Bogus\n\ +*EFRaster False/Print: \"\"\n\ +*EFRaster Hold: \"\"\n\ +*CloseUI: *EFRaster\n"; + +#[test] +fn printer_options_come_from_the_ppd() { + let options = spool::parse_ppd(&spool::ppd_text(FIERY_PPD)); + let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect(); + assert_eq!(keys, ["InputSlot", "EFMediaType", "EFRaster"], "no installable options, no PageSize"); + let tray = &options[0]; + assert_eq!((tray.label.as_str(), tray.group.as_str(), tray.default.as_str()), ("Paper tray", "Media", "AutoSelect")); + assert_eq!( + tray.choices, + [("AutoSelect".into(), "Auto tray select".into()), ("ManualFeed".into(), "Bypass tray".into()), ("Tray2".into(), "Tray 2".into())], + "PostScript lines and translations (*fr.InputSlot) are not choices" + ); + assert_eq!(options[1].choices[1].1, "Thick 1 (106\u{ad}163 g/m²)", "Latin-1 labels"); + // A default that isn't one of the choices falls back to the first; a choice without a label shows its keyword. + assert_eq!((options[2].default.as_str(), options[2].choices[1].1.as_str()), ("False", "Hold")); + assert!(spool::parse_ppd("*OpenUI *Broken\n*Broken A/B\n").is_empty(), "unclosed or malformed blocks are skipped"); +} + +#[test] +fn printer_option_defaults_and_job_arguments() { + let current = + spool::parse_lpoptions("InputSlot/Paper tray: AutoSelect *Tray2 ManualFeed\nEFRaster/Print queue action: *False Hold\nbroken line\n"); + assert_eq!(current, [("InputSlot".to_string(), "Tray2".to_string()), ("EFRaster".to_string(), "False".to_string())]); + let job = Job { + options: vec![ + ("InputSlot".into(), "Tray2".into()), + ("EFMediaType".into(), "Heavy1".into()), + ("Duplex".into(), "DuplexTumble".into()), + ("Bad Key".into(), "x".into()), + ("EFRaster".into(), "a=b".into()), + ], + ..Job::default() + }; + let args = lp_args(&job).join(" "); + assert!(args.ends_with("-o fit-to-page=false -o InputSlot=Tray2 -o EFMediaType=Heavy1"), "{args}"); + assert!(spool::printer_options("../../etc/passwd").is_empty(), "not a queue name"); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 4f86638a79..cc047f5a2c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2061,3 +2061,9 @@ action wizard Start Старт yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy или yy година · m или mm месец · mmm или mmmm име на месеца · d или dd ден · ddd или dddd ден от седмицата. H, h, M, s и t са букви за час и не могат да се използват самостоятелно: поставете {escape} пред буква, за да се покаже както е. Same as interface ({language}) Като интерфейса ({language}) {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} все още не може да се запише в PDF: текстът при попълване и подписване поддържа само западноевропейски знаци. Изберете числов формат, например dd.mm.yyyy. + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. Отваря предпочитанията на драйвера на принтера. Те се запазват за всеки печат от този компютър; избраните тук копия, двустранен печат, сива скала и хартия продължават да важат. + The printer driver's own settings for this print: paper tray, paper type, finishing and more. Собствените настройки на драйвера на принтера за този печат: тава за хартия, вид хартия, довършителна обработка и други. + Couldn't open the printer's preferences: {e} Предпочитанията на принтера не можаха да се отворят: {e} + Printer properties Свойства на принтера + Reset to printer defaults Възстанови настройките по подразбиране на принтера + This printer offers no settings of its own. Този принтер няма собствени настройки. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8bb47e7990..8aa85e06ae 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2099,3 +2099,9 @@ action wizard Start Starten yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy oder yy Jahr · m oder mm Monat · mmm oder mmmm Monatsname · d oder dd Tag · ddd oder dddd Wochentag. H, h, M, s und t sind Zeitbuchstaben und können nicht allein verwendet werden: Setze {escape} vor einen Buchstaben, um ihn unverändert anzuzeigen. Same as interface ({language}) Wie die Oberfläche ({language}) {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} kann noch nicht in das PDF geschrieben werden: Text beim Ausfüllen und Unterschreiben ist auf westeuropäische Zeichen beschränkt. Wähle ein numerisches Format wie dd.mm.yyyy. + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. Öffnet die Einstellungen des Druckertreibers. Sie gelten für jeden Druck von diesem Computer; die hier gewählten Kopien, Duplex, Graustufen und das Papier gelten weiterhin. + The printer driver's own settings for this print: paper tray, paper type, finishing and more. Die eigenen Einstellungen des Druckertreibers für diesen Druck: Papierfach, Papiersorte, Endverarbeitung und mehr. + Couldn't open the printer's preferences: {e} Die Druckereinstellungen konnten nicht geöffnet werden: {e} + Printer properties Druckereigenschaften + Reset to printer defaults Auf Druckerstandard zurücksetzen + This printer offers no settings of its own. Dieser Drucker bietet keine eigenen Einstellungen. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5e81778915..079faac353 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2015,3 +2015,9 @@ yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy o yy año · m o mm mes · mmm o mmmm nombre del mes · d o dd día · ddd o dddd día de la semana. H, h, M, s y t son letras de hora y no pueden usarse solas: pon {escape} delante de una letra para mostrarla tal cual. Same as interface ({language}) Igual que la interfaz ({language}) {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} todavía no se puede escribir en el PDF: el texto de Rellenar y firmar solo admite caracteres de Europa occidental. Elige un formato numérico como dd.mm.yyyy. + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. Abre las preferencias del controlador de la impresora. Se guardan para todas las impresiones de este equipo; las copias, la impresión a doble cara, la escala de grises y el papel elegidos aquí se siguen aplicando. + The printer driver's own settings for this print: paper tray, paper type, finishing and more. Los ajustes propios del controlador de la impresora para esta impresión: bandeja, tipo de papel, acabado y más. + Couldn't open the printer's preferences: {e} No se pudieron abrir las preferencias de la impresora: {e} + Printer properties Propiedades de la impresora + Reset to printer defaults Restablecer los valores predeterminados de la impresora + This printer offers no settings of its own. Esta impresora no ofrece ajustes propios. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f4c9717060..e83f588cd9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2052,3 +2052,9 @@ yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy ou yy année · m ou mm mois · mmm ou mmmm nom du mois · d ou dd jour · ddd ou dddd jour de la semaine. H, h, M, s et t sont des lettres d'heure et ne peuvent pas être utilisées seules : placez {escape} devant une lettre pour l'afficher telle quelle. Same as interface ({language}) Comme l'interface ({language}) {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} ne peut pas encore être écrit dans le PDF : le texte de Remplir et signer est limité aux caractères d'Europe occidentale. Choisissez un format numérique comme dd.mm.yyyy. + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. Ouvre les préférences du pilote d'imprimante. Elles sont enregistrées pour toutes les impressions de cet ordinateur ; les copies, le recto verso, les niveaux de gris et le papier choisis ici s'appliquent toujours. + The printer driver's own settings for this print: paper tray, paper type, finishing and more. Les réglages propres au pilote d'imprimante pour cette impression : bac, type de papier, finition, etc. + Couldn't open the printer's preferences: {e} Impossible d'ouvrir les préférences de l'imprimante : {e} + Printer properties Propriétés de l'imprimante + Reset to printer defaults Rétablir les réglages par défaut de l'imprimante + This printer offers no settings of its own. Cette imprimante ne propose aucun réglage propre. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1d395e26b8..e2f73712c8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2097,3 +2097,9 @@ action wizard Start Indítás yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy vagy yy év · m vagy mm hónap · mmm vagy mmmm a hónap neve · d vagy dd nap · ddd vagy dddd a hét napja. A H, h, M, s és t időbetűk, önmagukban nem használhatók: tegyél {escape} jelet a betű elé, hogy változatlanul jelenjen meg. Same as interface ({language}) Mint a felület ({language}) {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} még nem írható a PDF-be: a kitöltés és aláírás szövege csak nyugat-európai karaktereket támogat. Válassz számokból álló formátumot, például dd.mm.yyyy. + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. Megnyitja a nyomtató-illesztőprogram beállításait. Ezek a számítógépről indított minden nyomtatásra érvényesek; az itt választott példányszám, kétoldalas nyomtatás, szürkeárnyalat és papír továbbra is érvényes. + The printer driver's own settings for this print: paper tray, paper type, finishing and more. A nyomtató-illesztőprogram saját beállításai ehhez a nyomtatáshoz: papírtálca, papírtípus, utófeldolgozás és egyebek. + Couldn't open the printer's preferences: {e} Nem sikerült megnyitni a nyomtató beállításait: {e} + Printer properties Nyomtató tulajdonságai + Reset to printer defaults A nyomtató alapértékeinek visszaállítása + This printer offers no settings of its own. Ennek a nyomtatónak nincsenek saját beállításai. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a34233e510..8d9d5511ed 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1991,3 +1991,9 @@ yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy または yy 年 · m または mm 月 · mmm または mmmm 月名 · d または dd 日 · ddd または dddd 曜日。H、h、M、s、t は時刻の文字なので単独では使えません。文字をそのまま表示するには前に {escape} を付けます。 Same as interface ({language}) 表示言語と同じ({language}) {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} はまだ PDF に書き込めません。入力と署名のテキストは西ヨーロッパの文字のみに対応しています。dd.mm.yyyy などの数字の形式を選んでください。 + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. プリンタードライバーの基本設定を開きます。設定はこのコンピューターからのすべての印刷に保存されます。ここで選んだ部数、両面、グレースケール、用紙は引き続き適用されます。 + The printer driver's own settings for this print: paper tray, paper type, finishing and more. この印刷に使うプリンタードライバー独自の設定:給紙トレイ、用紙の種類、後処理など。 + Couldn't open the printer's preferences: {e} プリンターの基本設定を開けませんでした: {e} + Printer properties プリンターのプロパティ + Reset to printer defaults プリンターの初期設定に戻す + This printer offers no settings of its own. このプリンターには独自の設定がありません。 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6641c93c46..12c8ef8ce4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2013,3 +2013,9 @@ yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy или yy — год · m или mm — месяц · mmm или mmmm — название месяца · d или dd — день · ddd или dddd — день недели. H, h, M, s и t — буквы времени, их нельзя использовать отдельно: поставьте {escape} перед буквой, чтобы показать её как есть. Same as interface ({language}) Как в интерфейсе ({language}) {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} пока нельзя записать в PDF: текст «Заполнить и подписать» поддерживает только западноевропейские символы. Выберите числовой формат, например dd.mm.yyyy. + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. Открывает настройки драйвера принтера. Они сохраняются для всей печати с этого компьютера; выбранные здесь копии, двусторонняя печать, оттенки серого и бумага по-прежнему применяются. + The printer driver's own settings for this print: paper tray, paper type, finishing and more. Собственные параметры драйвера принтера для этой печати: лоток, тип бумаги, финишная обработка и другое. + Couldn't open the printer's preferences: {e} Не удалось открыть настройки принтера: {e} + Printer properties Свойства принтера + Reset to printer defaults Вернуть параметры принтера по умолчанию + This printer offers no settings of its own. У этого принтера нет собственных параметров. diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 47db38dc7b..ebef861c62 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2031,3 +2031,9 @@ action wizard Start ప్రారంభించు yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy లేదా yy సంవత్సరం · m లేదా mm నెల · mmm లేదా mmmm నెల పేరు · d లేదా dd రోజు · ddd లేదా dddd వారం రోజు. H, h, M, s, t సమయ అక్షరాలు, వాటిని ఒంటరిగా ఉపయోగించలేరు: అక్షరాన్ని యథాతథంగా చూపడానికి దాని ముందు {escape} పెట్టండి. Same as interface ({language}) ఇంటర్‌ఫేస్ లాగానే ({language}) {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} ను ఇంకా PDF లో రాయలేము: నింపడం & సంతకం టెక్స్ట్ పశ్చిమ యూరోపియన్ అక్షరాలకు మాత్రమే పరిమితం. dd.mm.yyyy వంటి సంఖ్యా ఫార్మాట్‌ను ఎంచుకోండి. + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. ప్రింటర్ డ్రైవర్ ప్రాధాన్యతలను తెరుస్తుంది. అవి ఈ కంప్యూటర్ నుండి ప్రతి ముద్రణ కోసం సేవ్ అవుతాయి; ఇక్కడ ఎంచుకున్న కాపీలు, రెండు వైపులా, గ్రేస్కేల్ మరియు కాగితం ఇప్పటికీ వర్తిస్తాయి. + The printer driver's own settings for this print: paper tray, paper type, finishing and more. ఈ ముద్రణ కోసం ప్రింటర్ డ్రైవర్ స్వంత సెట్టింగ్‌లు: కాగితపు ట్రే, కాగితం రకం, ఫినిషింగ్ మరియు మరిన్ని. + Couldn't open the printer's preferences: {e} ప్రింటర్ ప్రాధాన్యతలను తెరవడం సాధ్యం కాలేదు: {e} + Printer properties ప్రింటర్ లక్షణాలు + Reset to printer defaults ప్రింటర్ డిఫాల్ట్‌లకు రీసెట్ చేయి + This printer offers no settings of its own. ఈ ప్రింటర్‌కు దాని స్వంత సెట్టింగ్‌లు లేవు. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0cdef4e28e..1dc65fc0d3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2119,3 +2119,9 @@ calendar date December грудня yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy або yy рік · m або mm місяць · mmm або mmmm назва місяця · d або dd день · ddd або dddd день тижня. H, h, M, s і t — літери часу, їх не можна використовувати окремо: поставте {escape} перед літерою, щоб показати її як є. Same as interface ({language}) Як в інтерфейсі ({language}) {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} поки не можна записати в PDF: текст заповнення й підпису підтримує лише західноєвропейські символи. Виберіть числовий формат, наприклад dd.mm.yyyy. + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. Відкриває налаштування драйвера принтера. Вони зберігаються для кожного друку з цього комп’ютера; вибрані тут кількість копій, двосторонній друк, відтінки сірого й папір і далі діють. + The printer driver's own settings for this print: paper tray, paper type, finishing and more. Власні налаштування драйвера принтера для цього друку: лоток, тип паперу, післядрукова обробка тощо. + Couldn't open the printer's preferences: {e} Не вдалося відкрити налаштування принтера: {e} + Printer properties Властивості принтера + Reset to printer defaults Скинути до налаштувань принтера за замовчуванням + This printer offers no settings of its own. Цей принтер не має власних налаштувань. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d8cdb1b503..3fdbc568ed 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2063,3 +2063,9 @@ yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy 或 yy 年 · m 或 mm 月 · mmm 或 mmmm 月份名称 · d 或 dd 日 · ddd 或 dddd 星期。H、h、M、s 和 t 是时间字母,不能单独使用:在字母前加 {escape} 可按原样显示。 Same as interface ({language}) 与界面相同({language}) {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. 暂时无法将 {chars} 写入 PDF:填写与签名的文字仅支持西欧字符。请选择数字格式,例如 dd.mm.yyyy。 + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. 打开打印机驱动程序的首选项。这些设置会保存并用于此电脑上的所有打印;此处选择的份数、双面、灰度和纸张仍然适用。 + The printer driver's own settings for this print: paper tray, paper type, finishing and more. 此次打印的打印机驱动程序专属设置:纸盒、纸张类型、装订整理等。 + Couldn't open the printer's preferences: {e} 无法打开打印机首选项:{e} + Printer properties 打印机属性 + Reset to printer defaults 恢复打印机默认设置 + This printer offers no settings of its own. 此打印机没有专属设置。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e2b2403f46..18fd93015b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1984,3 +1984,9 @@ yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy 或 yy 年 · m 或 mm 月 · mmm 或 mmmm 月份名稱 · d 或 dd 日 · ddd 或 dddd 星期。H、h、M、s 和 t 是時間字母,不能單獨使用:在字母前加上 {escape} 即可照原樣顯示。 Same as interface ({language}) 與介面相同({language}) {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. 暫時無法將 {chars} 寫入 PDF:填寫與簽署的文字僅支援西歐字元。請選擇數字格式,例如 dd.mm.yyyy。 + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. 開啟印表機驅動程式的喜好設定。這些設定會儲存並用於此電腦的所有列印;此處選擇的份數、雙面、灰階和紙張仍然適用。 + The printer driver's own settings for this print: paper tray, paper type, finishing and more. 此次列印的印表機驅動程式專屬設定:紙匣、紙張類型、裝訂整理等。 + Couldn't open the printer's preferences: {e} 無法開啟印表機喜好設定:{e} + Printer properties 印表機內容 + Reset to printer defaults 還原印表機預設值 + This printer offers no settings of its own. 此印表機沒有專屬設定。 diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 951b1d314f..88488ad6a9 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -59,6 +59,13 @@ pub struct PrintDraft { /// The sheet shown in the preview (0-based). pub sheet: usize, pub current_page: usize, + /// The printer driver's own options and the printer they were read for (Properties…, CUPS). + pub driver_options: Option<(String, Vec)>, + /// Driver options set away from the printer's defaults, for `driver_options`' printer. + pub driver_choices: std::collections::BTreeMap, + pub show_driver_options: bool, + /// Why the printer's preferences window didn't open (Windows). + pub driver_error: Option, } impl Default for PrintDraft { @@ -92,6 +99,10 @@ impl Default for PrintDraft { paper: 0, sheet: 0, current_page: 0, + driver_options: None, + driver_choices: Default::default(), + show_driver_options: false, + driver_error: None, } } } @@ -139,6 +150,11 @@ impl PrintDraft { duplex: self.duplex, grayscale: self.grayscale, title: title.to_string(), + // Only for the printer the options were read from. + options: match (&self.printer, &self.driver_options) { + (Some(p), Some((read_for, _))) if p == read_for => self.driver_choices.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + _ => Vec::new(), + }, } } } @@ -253,6 +269,61 @@ impl PdfCraftApp { } } +/// Properties… on CUPS: the driver's options for this print, grouped as the driver groups them. +/// Only choices away from the printer's defaults are kept, and sent with the job. +fn driver_options_panel(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { + let PrintDraft { driver_options: Some((_, options)), driver_choices: choices, show_driver_options: show, .. } = d else { return }; + egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Printer properties")).font(theme::semibold(13.0))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(tl!("Done")).clicked() { + *show = false; + } + if ui.add_enabled(!choices.is_empty(), egui::Button::new(tl!("Reset to printer defaults"))).clicked() { + choices.clear(); + } + }); + }); + if options.is_empty() { + ui.label(egui::RichText::new(tl!("This printer offers no settings of its own.")).small().color(t.text_muted)); + return; + } + egui::ScrollArea::vertical().id_salt("driver-options").max_height(240.0).show(ui, |ui| { + for (n, chunk) in options.chunk_by(|a, b| a.group == b.group).enumerate() { + let group = chunk.first().map_or("", |o| o.group.as_str()); + if !group.is_empty() { + ui.add_space(4.0); + // Option and group names are the driver's own text. + ui.label(egui::RichText::new(group).small().color(t.text_muted)); + } + egui::Grid::new(("driver-group", n)).num_columns(2).spacing([10.0, 4.0]).show(ui, |ui| { + for o in chunk { + ui.label(&o.label); + let current = choices.get(&o.key).cloned().unwrap_or_else(|| o.default.clone()); + let label_of = |c: &str| o.choices.iter().find(|(k, _)| k == c).map_or_else(|| c.to_string(), |(_, l)| l.clone()); + let mut pick = current.clone(); + egui::ComboBox::from_id_salt(("driver-option", &o.key)).selected_text(label_of(¤t)).width(200.0).show_ui(ui, |ui| { + for (k, l) in &o.choices { + ui.selectable_value(&mut pick, k.clone(), l.as_str()); + } + }); + if pick != current { + if pick == o.default { + choices.remove(&o.key); + } else { + choices.insert(o.key.clone(), pick); + } + } + ui.end_row(); + } + }); + } + }); + }); +} + fn combo(ui: &mut egui::Ui, id: &str, value: &mut T, choices: &[(T, &str)], width: f32) { let shown = choices.iter().find(|c| c.0 == *value).map_or("", |c| c.1); egui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| { @@ -284,14 +355,40 @@ pub(crate) fn body( ui.set_width(470.0); egui::Grid::new("print-top").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { ui.label(tl!("Printer:")); - let shown = d.printer.clone().unwrap_or_else(|| tl!("Save as PDF").to_string()); - egui::ComboBox::from_id_salt("printer").selected_text(shown).width(260.0).show_ui(ui, |ui| { - for p in &d.printers { - let label = if p.default { crate::i18n::fmt(tl!("{name} (default)"), &[("name", &p.name)]) } else { p.name.clone() }; - ui.selectable_value(&mut d.printer, Some(p.name.clone()), label); + let before = d.printer.clone(); + ui.horizontal(|ui| { + let shown = d.printer.clone().unwrap_or_else(|| tl!("Save as PDF").to_string()); + egui::ComboBox::from_id_salt("printer").selected_text(shown).width(260.0).show_ui(ui, |ui| { + for p in &d.printers { + let label = if p.default { crate::i18n::fmt(tl!("{name} (default)"), &[("name", &p.name)]) } else { p.name.clone() }; + ui.selectable_value(&mut d.printer, Some(p.name.clone()), label); + } + ui.selectable_value(&mut d.printer, None, tl!("Save as PDF")); + }); + let tip = if spool::HAS_PRINTER_PREFERENCES { + tl!("Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply.") + } else { + tl!("The printer driver's own settings for this print: paper tray, paper type, finishing and more.") + }; + let properties = ui.add_enabled(d.printer.is_some(), egui::Button::new(tl!("Properties…"))).on_hover_text(tip); + if properties.clicked() + && let Some(p) = d.printer.clone() + { + if spool::HAS_PRINTER_PREFERENCES { + d.driver_error = spool::open_printer_preferences(&p).err().map(|e| e.to_string()); + } else { + if d.driver_options.as_ref().is_none_or(|(read_for, _)| *read_for != p) { + d.driver_options = Some((p.clone(), spool::printer_options(&p))); + d.driver_choices.clear(); + } + d.show_driver_options = !d.show_driver_options; + } } - ui.selectable_value(&mut d.printer, None, tl!("Save as PDF")); }); + if d.printer != before { + d.show_driver_options = false; + d.driver_error = None; + } ui.end_row(); ui.label(tl!("Copies:")); ui.horizontal(|ui| { @@ -321,6 +418,12 @@ pub(crate) fn body( }); ui.end_row(); }); + if let Some(e) = &d.driver_error { + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Couldn't open the printer's preferences: {e}"), &[("e", e)])).small().color(t.text_muted)); + } + if d.show_driver_options { + driver_options_panel(ui, d, t); + } ui.add_space(6.0); widgets::section_title(ui, tl!("Pages to Print")); ui.horizontal(|ui| { diff --git a/crates/ui-egui/tests/print.rs b/crates/ui-egui/tests/print.rs index 53ad1b8653..82af1860c5 100644 --- a/crates/ui-egui/tests/print.rs +++ b/crates/ui-egui/tests/print.rs @@ -152,3 +152,41 @@ fn a_failed_save_as_pdf_keeps_the_dialog_open() { let toast = h.state().toast.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); assert!(toast.contains("Could not save"), "the user is told why: {toast:?}"); } + +/// Properties… shows the printer driver's own options (CUPS) and sends only those changed. +#[test] +fn print_dialog_shows_the_printer_driver_options() { + use pdfcraft_engine::print::spool; + let mut h = harness(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + h.state_mut().print_draft.printer = None; + h.run_steps(1); + assert!(h.query_by(|n| n.label().as_deref() == Some("Properties…") && n.is_disabled()).is_some(), "Save as PDF has no driver"); + let tray = spool::PrinterOption { + key: "InputSlot".into(), + label: "Paper tray".into(), + group: "Media".into(), + choices: vec![("AutoSelect".into(), "Auto tray select".into()), ("Tray2".into(), "Tray 2".into())], + default: "AutoSelect".into(), + }; + { + let d = &mut h.state_mut().print_draft; + d.printer = Some("Test_Queue".into()); + d.driver_options = Some(("Test_Queue".into(), vec![tray])); + d.driver_choices.insert("InputSlot".into(), "Tray2".into()); + d.show_driver_options = true; + } + h.run_steps(2); + h.get_by_label("Printer properties"); + h.get_by_label("Media"); + h.get_by_label("Paper tray"); + assert_eq!(h.state().print_draft.job("form.pdf").options, [("InputSlot".to_string(), "Tray2".to_string())]); + h.get_by_label("Reset to printer defaults").click(); + h.run_steps(2); + assert!(h.state().print_draft.job("form.pdf").options.is_empty(), "defaults are not sent"); + // Options read for another printer never go with this one's job. + h.state_mut().print_draft.driver_choices.insert("InputSlot".into(), "Tray2".into()); + h.state_mut().print_draft.printer = Some("Other_Queue".into()); + assert!(h.state().print_draft.job("form.pdf").options.is_empty()); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4655479a14..685b19daa5 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7829,9 +7829,9 @@ tier = "P0" milestone = "M10" status = "shipped" commands = ["print.dialog"] -tools = ["printers", "doc_print"] -evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"] -notes = "CUPS printers via lpstat; Save as PDF; Windows spooler pending" +tools = ["printers", "printer_options", "doc_print"] +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/print.rs::print_dialog_shows_the_printer_driver_options"] +notes = "CUPS printers via lpstat; Save as PDF; Windows spooler pending. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job" [[feature]] id = "print.copies-collate"