diff --git a/ROADMAP.md b/ROADMAP.md index 224ae12204..e566ade4d2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -528,6 +528,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732. - **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off. +- **2026-10-14 (M10, #2001):** The Print dialog remembers two-sided, grayscale, collate, page sizing (with its custom percent) and Comments & forms across restarts, under the names `doc_print` uses; copies, pages, layout and orientation still start fresh for each job. The option names now live in one place (`pdfcraft_print::names`) for the tool and the settings file. - **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it. - **2026-10-14 (M5, comment pop-ups; reworked 2026-11-01):** A double-click on a comment opens its pop-up window on the page: a title bar with the comment's kind, author and date; its text and its replies' typed in place, the replies as panels each inside the one it answers, a Reply button under each; the window keeps its `/Rect` size on screen as the page zooms, scrolls its thread, drags by its title bar, resizes by its corner and closes with ×, each an undo step saved in the file's `/Popup` so other viewers agree. A faint wedge in the comment's colour joins the comment to its open window on roll-over or selection. `comment_popup` does the same headlessly and `comment_list` reports the pop-up. - **2026-10-15 (M6, #1741):** Custom stamps can be dynamic: Create Custom Stamp adds an optional line under the picture, a template with {name}, {date} and {time} filled in when the stamp is placed (the built-in dynamic stamps' format), kept in the stamp library; `stamp_custom` takes the same `line`. diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index c8a8b2bf06..cd98596b9c 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -1,7 +1,7 @@ //! Printing tools: list printers, and print (or save the print-ready PDF) with Acrobat's Print //! dialog options. -use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool}; +use pdfcraft_engine::print::{self, Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool}; use serde_json::{Value, json}; use crate::{Args, Automation, Result, ToolError, failed, write_atomic}; @@ -62,8 +62,17 @@ fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { /// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default /// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752), and tells /// CUPS which way the laid-out `sheets` run, as the Print dialog does (#2713): an orientation the -/// caller chose in `options` stands. -fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: usize, sheets: &[print::Sheet]) -> Result { +/// caller chose in `options` stands. It asks the driver for grayscale only when the pages aren't +/// grey pictures already ([`print::driver_needs_grayscale`]). +fn spool_job( + a: &Args, + printer: &str, + title: String, + duplex: Option, + paper: usize, + raster: Option, + sheets: &[print::Sheet], +) -> Result { let options = match a.get("options") { None => Vec::new(), Some(Value::Object(m)) => m @@ -81,7 +90,7 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option = options.into_iter().chain(print::media_option(paper)).collect(); @@ -174,9 +183,6 @@ impl Automation { let pages = print::select_pages(count, a.opt_str("pages")?, &labels, subset, a.opt_bool("reverse")?.unwrap_or(false)) .map_err(|e| bad(e.to_string()))?; let layout = match a.opt_str("layout")?.unwrap_or("fit") { - "fit" => Layout::Size(SizeMode::Fit), - "actual" => Layout::Size(SizeMode::Actual), - "shrink" => Layout::Size(SizeMode::Shrink), "custom" => Layout::Size(SizeMode::Custom(a.opt_num("scale")?.ok_or_else(|| bad("custom needs scale (percent)"))?)), "multiple" => { // Custom pages per sheet: `cols` across by `rows` down, both or neither. @@ -227,7 +233,7 @@ impl Automation { overlap: a.opt_num("overlap")?.unwrap_or(18.0), cut_marks: a.opt_bool("cut_marks")?.unwrap_or(true), }, - l => return Err(bad(format!("unknown layout {l:?}"))), + l => Layout::Size(print::names::parse_size_mode(l, 100.0).ok_or_else(|| bad(format!("unknown layout {l:?}")))?), }; let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?; if (a.opt_int("cols")?.is_some() || a.opt_int("rows")?.is_some()) && !matches!(layout, Layout::Multiple { .. }) { @@ -242,13 +248,8 @@ impl Automation { "landscape" => Orientation::Landscape, o => return Err(bad(format!("unknown orientation {o:?}"))), }; - let content = match a.opt_str("comments_forms")?.unwrap_or("document-and-markups") { - "document" => Content::Document, - "document-and-markups" => Content::DocumentAndMarkups, - "document-and-stamps" => Content::DocumentAndStamps, - "form-fields-only" => Content::FormFieldsOnly, - c => return Err(bad(format!("unknown comments_forms {c:?}"))), - }; + let content = a.opt_str("comments_forms")?.unwrap_or("document-and-markups"); + let content = print::names::parse_content(content).ok_or_else(|| bad(format!("unknown comments_forms {content:?}")))?; let (paper_index, paper) = paper_named(a.opt_str("paper")?)?; // Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when // `grayscale` is also asked for. @@ -284,7 +285,7 @@ impl Automation { out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { - let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, &laid_out)?; + let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, settings.raster, &laid_out)?; out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), @@ -330,7 +331,7 @@ mod tests { #[test] fn printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them() { let sheets = |sizes: &[(f64, f64)]| sizes.iter().map(|&size| print::Sheet { size, ..Default::default() }).collect::>(); - let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, 0, s).unwrap().options; + let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, 0, None, s).unwrap().options; let media = ("media".to_string(), "Letter".to_string()); let landscape = sheets(&[(792.0, 612.0), (792.0, 612.0)]); let turned = if cfg!(windows) { vec![media.clone()] } else { vec![media.clone(), ("orientation-requested".to_string(), "4".to_string())] }; @@ -347,7 +348,7 @@ mod tests { fn printing_to_a_printer_feeds_the_laid_out_paper() { let job = |args: Value, paper_name: Option<&str>| { let (index, _) = paper_named(paper_name).unwrap(); - spool_job(&Args(&args), "Office", "a.pdf".into(), None, index, &[]).unwrap() + spool_job(&Args(&args), "Office", "a.pdf".into(), None, index, None, &[]).unwrap() }; let media = |m: &str| ("media".to_string(), m.to_string()); // US Letter when no paper is asked for, after the driver's own options. @@ -363,7 +364,21 @@ 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"))); - 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()); + assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0, None, &[]).ok().map(|j| j.printer), Some(None)); + assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0, None, &[]).is_err()); + } + + /// #5806: grayscale with Print as image sends grey pictures, so the driver isn't asked for + /// grayscale, which Microsoft Print to PDF keeps no setting for and the job was refused. + #[test] + fn grey_pictures_dont_ask_the_driver_for_grayscale() { + let grey = print::Raster { dpi: 300.0, grayscale: true }; + let colour = print::Raster { dpi: 300.0, grayscale: false }; + let job = + |args: Value, raster: Option| spool_job(&Args(&args), "Office", String::new(), None, 0, raster, &[]).unwrap().grayscale; + assert!(job(json!({ "grayscale": true }), None), "vector pages: the driver makes them grey"); + assert!(!job(json!({ "grayscale": true }), Some(grey)), "grey pictures: nothing for the driver to do"); + assert!(job(json!({ "grayscale": true }), Some(colour)), "colour pictures still need the driver"); + assert!(!job(json!({}), None)); } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index fd275610c9..bf27b7dfa6 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -776,7 +776,7 @@ pub fn tools() -> Vec { "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer sends no setting, so the printer's own default applies." }, "grayscale": { "type": "boolean" }, "area": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Print a selected area: [x0, y0, x1, y1] in points from the top-left of the displayed page, of the one page in pages, sized by layout fit/actual/shrink/custom." }, - "as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey." }, + "as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey, so the printer isn't asked for grayscale (a driver without that setting still prints them)." }, "dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." }, "options": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Printer driver options from printer_options: key → choice value." }, }), diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index f40f175d69..97a3b60ab9 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -351,10 +351,12 @@ impl Stream { } /// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data - /// decoded before the damage is returned). Streams with image codecs fail with + /// decoded before the damage is returned). Decoding past + /// [`crate::budget::MAX_PAGE_DECODED`] (256 MiB), the most the renderer reads from one + /// stream, is an error. Streams with image codecs fail with /// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage. pub fn decoded(&self) -> Result, CosError> { - self.decoded_within(MAX_DECODED) + self.decoded_within(crate::budget::MAX_PAGE_DECODED) } /// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than @@ -780,6 +782,13 @@ mod tests { assert_eq!(stream.decoded_strict().unwrap(), b"A"); } + #[test] + fn decoded_stops_at_renderer_cap_of_256_mib() { + let data = vec![0u8; (256 << 20) + 1]; + let stream = Stream::flate(Dict::new(), &data); + assert!(matches!(stream.decoded(), Err(CosError::Filter(_)))); + } + #[test] fn first_filter_decodes_only_the_outer_wrapper() { let data = "0 0 m 100 100 l S\n".repeat(64).into_bytes(); diff --git a/crates/print/README.md b/crates/print/README.md index ba1de0cac7..9866f788a3 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -34,6 +34,17 @@ spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default() the GUI thread, and gives `lpoptions` ten seconds: a CUPS that has stopped answering leaves the PPD's own defaults in force instead of holding the dialog (#1818; `spool::output_within` kills and reaps the command at the deadline). +- **Remembered choices** (#2001): the dialog keeps two-sided (including leaving it to the + printer), grayscale, collate, page sizing (with its custom percent), Comments & forms and a + booklet's binding side across restarts, in the settings file under `print` with the names + `doc_print` takes (`names::duplex`, `names::content`, `names::size_mode` and their `parse_*` + counterparts are the one vocabulary). The paper is saved by its own setting (#2156). +- **Each job starts afresh**: copies, pages (the choice, the range, odd/even and reverse), the + layout with its own options (pages per sheet, order, borders, booklet subset, poster scale, + overlap and cut marks) and orientation reset to the defaults each time Print opens, in the + same session too; ISO 32000-2 Table 147 initialises them per document when the print dialog + opens. Picked thumbnails still open it on Selected pages. The printer picked last carries over + within a session (#3537) but is not saved. Each source page becomes a Form XObject (its content wrapped in q/Q, plus the printable annotations); sheets place them with a clip. The result is a fresh, unencrypted, diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 74810db8d6..fca266b870 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -38,10 +38,122 @@ use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents, pub use imposition::{impose, keep_printed, sheet_pages}; pub use settings::{ Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, MAX_GRID, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, - cups_media, media_option, + cups_media, driver_needs_grayscale, media_option, }; #[cfg(test)] use sheets::text_direction; pub use sheets::{ LABEL_MARGIN, MARGIN, Placement, Sheet, add_cups_orientation, cups_orientation, layout, matching_paper, text_reads_up, texts_read_up, }; + +/// The names of the Print dialog's choices, shared by the `doc_print` tool and the settings +/// file, so a saved preference reads like a tool argument and there is one vocabulary to keep. +/// Each `parse_*` returns `None` for an unknown name (settings and tool arguments are +/// untrusted input; the caller keeps its default). +pub mod names { + use super::{Binding, Content, SizeMode, spool::Duplex}; + + /// Two-sided printing as `doc_print` names it: `None` leaves it to the printer (`printer`). + pub fn duplex(duplex: Option) -> &'static str { + match duplex { + None => "printer", + Some(Duplex::Off) => "off", + Some(Duplex::LongEdge) => "long-edge", + Some(Duplex::ShortEdge) => "short-edge", + } + } + + /// [`duplex`] read back; `None` for a name it doesn't give. + pub fn parse_duplex(name: &str) -> Option> { + match name { + "printer" => Some(None), + "off" => Some(Some(Duplex::Off)), + "long-edge" => Some(Some(Duplex::LongEdge)), + "short-edge" => Some(Some(Duplex::ShortEdge)), + _ => None, + } + } + + /// Acrobat's Comments & Forms choices. + pub fn content(content: Content) -> &'static str { + match content { + Content::Document => "document", + Content::DocumentAndMarkups => "document-and-markups", + Content::DocumentAndStamps => "document-and-stamps", + Content::FormFieldsOnly => "form-fields-only", + } + } + + pub fn parse_content(name: &str) -> Option { + match name { + "document" => Some(Content::Document), + "document-and-markups" => Some(Content::DocumentAndMarkups), + "document-and-stamps" => Some(Content::DocumentAndStamps), + "form-fields-only" => Some(Content::FormFieldsOnly), + _ => None, + } + } + + /// The page sizing choices; a custom scale is named `custom` and carries its percent + /// separately (see [`parse_size_mode`]). + pub fn size_mode(mode: SizeMode) -> &'static str { + match mode { + SizeMode::Fit => "fit", + SizeMode::Actual => "actual", + SizeMode::Shrink => "shrink", + SizeMode::Custom(_) => "custom", + } + } + + /// `custom` takes `scale` as its percent; the caller has validated it. + pub fn parse_size_mode(name: &str, scale: f64) -> Option { + match name { + "fit" => Some(SizeMode::Fit), + "actual" => Some(SizeMode::Actual), + "shrink" => Some(SizeMode::Shrink), + "custom" => Some(SizeMode::Custom(scale)), + _ => None, + } + } + + /// A booklet's binding side. + pub fn binding(binding: Binding) -> &'static str { + match binding { + Binding::Left => "left", + Binding::Right => "right", + } + } + + pub fn parse_binding(name: &str) -> Option { + match name { + "left" => Some(Binding::Left), + "right" => Some(Binding::Right), + _ => None, + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn every_choice_round_trips_through_its_name_and_unknown_names_are_none() { + for d in [None, Some(Duplex::Off), Some(Duplex::LongEdge), Some(Duplex::ShortEdge)] { + assert_eq!(parse_duplex(duplex(d)), Some(d)); + } + for c in [Content::Document, Content::DocumentAndMarkups, Content::DocumentAndStamps, Content::FormFieldsOnly] { + assert_eq!(parse_content(content(c)), Some(c)); + } + for m in [SizeMode::Fit, SizeMode::Actual, SizeMode::Shrink, SizeMode::Custom(62.5)] { + assert_eq!(parse_size_mode(size_mode(m), 62.5), Some(m)); + } + for b in [Binding::Left, Binding::Right] { + assert_eq!(parse_binding(binding(b)), Some(b)); + } + assert_eq!(parse_binding("Right"), None); + assert_eq!(parse_duplex("Long-Edge"), None); + assert_eq!(parse_content(""), None); + assert_eq!(parse_size_mode("fit-to-page", 100.0), None); + } + } +} diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index e0f981f01c..29540c8c19 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -182,6 +182,14 @@ impl Default for Raster { } } +/// Whether a print asked to be `grayscale` needs the printer's driver to print in grayscale. +/// Not when the pages go as grey pictures (`raster` with [`Raster::grayscale`]): they are grey +/// already, so a driver that keeps no grayscale setting (Microsoft Print to PDF, many inkjets) +/// still prints them instead of the job being refused (#5806). +pub fn driver_needs_grayscale(grayscale: bool, raster: Option) -> bool { + grayscale && !raster.is_some_and(|r| r.grayscale) +} + #[derive(Clone, Copy, Debug, PartialEq)] pub struct Crop { pub rect: [f64; 4], @@ -204,7 +212,7 @@ impl Default for Settings { } /// Common paper sizes (points, portrait). -pub const PAPERS: [(&str, (f64, f64)); 21] = [ +pub const PAPERS: [(&str, (f64, f64)); 23] = [ ("US Letter", (612.0, 792.0)), ("US Legal", (612.0, 1008.0)), ("Tabloid", (792.0, 1224.0)), @@ -226,6 +234,8 @@ pub const PAPERS: [(&str, (f64, f64)); 21] = [ ("A0", (2383.94, 3370.39)), ("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)), ("US #10 Envelope (4.125 x 9.5 in)", (297.0, 684.0)), + ("Folio (8.5 x 13 in)", (612.0, 936.0)), + ("F4 (210 x 330 mm)", (595.28, 935.43)), ]; /// `PAPERS` index of the 4 × 6 in shipping label. @@ -260,6 +270,8 @@ pub fn cups_media(index: usize) -> Option<&'static str> { "A1" => "iso_a1_594x841mm", "A0" => "iso_a0_841x1189mm", "US #10 Envelope (4.125 x 9.5 in)" => "Env10", + "Folio (8.5 x 13 in)" => "na_foolscap_8.5x13in", + "F4 (210 x 330 mm)" => "om_folio_210x330mm", _ if index == LABEL_PAPER => "w288h432", // A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's // paper stands, as it did for every size before #1583. diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index b16f57026d..fd61301209 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -164,10 +164,14 @@ pub(crate) fn printer_options_via_within( ) -> Vec { let (queue, instance) = printer.split_once('/').map_or((printer, None), |(q, i)| (q, Some(i))); // Retain the conservative name checks, applied to each component. Neither component is - // a path: reject dot components, backslashes and any additional separator before I/O. + // a path: reject dot components, backslashes and any additional separator before I/O. A + // leading `-` is refused too: `lpoptions` parses its argv with getopt, so `-p -E -l` would + // read `-E` as an option rather than as the destination, and `--` cannot help because it + // ends option parsing before the `-p` that names the queue. let valid = |name: &str| { !name.is_empty() && name.len() <= 64 + && !name.starts_with('-') && !matches!(name, "." | "..") && name.bytes().all(|b| b.is_ascii_graphic() && !matches!(b, b'/' | b'\\' | b':' | b'=' | b'"')) }; @@ -181,11 +185,20 @@ pub(crate) fn printer_options_via_within( f.take(PPD_CAP).read_to_end(&mut bytes).ok().map(|_| bytes) }); let mut options = ppd.as_deref().map(|b| parse_ppd(&ppd_text(b))).unwrap_or_default(); + let lpoptions_out = output_within(lpoptions.args(["-p", printer, "-l"]), limit).ok(); + let current = lpoptions_out.as_deref().map(|out| parse_lpoptions(&String::from_utf8_lossy(out))).unwrap_or_default(); + if options.is_empty() { + // The PPD is missing or unreadable (#4699). The same `lpoptions -l` listing carries every + // option with its choices and default; build a fallback list from it. + if let Some(out) = &lpoptions_out { + options = parse_lpoptions_list(&String::from_utf8_lossy(out)); + } + } + // After the fallback: a readable PPD whose `*OpenUI` blocks yielded nothing still supplies the + // translations, and `localize` leaves options it cannot translate untouched. if let Some(b) = &ppd { localize(&mut options, b, lang); } - let current = - output_within(lpoptions.args(["-p", printer, "-l"]), limit).map(|out| parse_lpoptions(&String::from_utf8_lossy(&out))).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) @@ -196,6 +209,108 @@ pub(crate) fn printer_options_via_within( options } +/// Caps that match [`craft_print::parse_ppd`]: a malformed spooler answer is not allowed to +/// dominate memory or the dialog. +#[cfg(all(unix, not(target_arch = "wasm32")))] +const MAX_LPOPTIONS_OPTIONS: usize = 400; +#[cfg(all(unix, not(target_arch = "wasm32")))] +const MAX_LPOPTIONS_CHOICES: usize = 400; +#[cfg(all(unix, not(target_arch = "wasm32")))] +const MAX_LPOPTIONS_LABEL: usize = 256; + +/// A PPD keyword: printable ASCII without spaces, `/`, `:`, `=` or `"`. +#[cfg(all(unix, not(target_arch = "wasm32")))] +// The three name helpers below mirror `is_ppd_keyword`, `name_label` and `ppd_label` in craft-print, +// which are private there: keep them in step with that crate, or they will accept in one reader what +// the other refuses. +fn is_lpoptions_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. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn lpoptions_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()), + } +} + +/// `label`, cut to [`MAX_LPOPTIONS_LABEL`] characters. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn lpoptions_label(label: &str) -> String { + label.chars().take(MAX_LPOPTIONS_LABEL).collect() +} + +/// Whether `choice` is a custom-value template. For a choice the user has to type, `lpoptions -l` +/// prints `Custom`, `Custom.` (`Custom.PASSCODE`, `Custom.STRING`, …) or `Custom.WIDTHxHEIGHT` +/// in place of the choices, and the value is not something Craft can collect: it would be sent to +/// the driver as the literal text. The prefix is reserved by CUPS, case-insensitively. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn is_lpoptions_custom_template(choice: &str) -> bool { + match choice.split_once('.') { + Some((name, _)) => name.eq_ignore_ascii_case("Custom"), + None => choice.eq_ignore_ascii_case("Custom"), + } +} + +/// Build [`PrinterOption`]s from the `lpoptions -p NAME -l` listing (`Key/Label: a *b c`). This is +/// the fallback when the PPD cannot be read, so there is no group information: every option lands in +/// one unnamed group, and the listing cannot say which options sit under `InstallableOptions`, so an +/// optional-hardware entry that [`parse_ppd`] drops may appear here. The starred choice is the +/// driver's default. Options Craft handles itself ([`HANDLED_OPTIONS`]) and custom-value templates +/// ([`is_lpoptions_custom_template`]) are skipped. +/// +/// The line is split at its **last** colon, not its first: `list_group()` prints the option as +/// `"%s/%s:"` from the PPD's keyword and translated text, then every choice as a bare `" %s"`, so +/// text such as `Supporto: Tipo` carries colons while a choice keyword cannot. Splitting at the +/// first colon would turn the rest of the label into invented choices. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn parse_lpoptions_list(out: &str) -> Vec { + let mut options = Vec::new(); + for line in out.lines() { + let line = line.trim_end_matches('\r'); + let Some((head, choices)) = line.rsplit_once(':') else { + continue; + }; + let (key, label) = lpoptions_name_label(head); + if !is_lpoptions_keyword(key) || HANDLED_OPTIONS.contains(&key) { + continue; + } + let mut choice_list = Vec::new(); + let mut default = String::new(); + // Set when the choice CUPS marks is a template the dialog cannot collect. The printer keeps + // that value, so standing in the first retained choice would show a default the job does not + // use: only options changed away from the default are submitted. + let mut marked_template = false; + for token in choices.split_whitespace() { + let starred = token.starts_with('*'); + let (choice_key, choice_label) = lpoptions_name_label(token.strip_prefix('*').unwrap_or(token)); + if !is_lpoptions_keyword(choice_key) || is_lpoptions_custom_template(choice_key) { + marked_template |= starred && is_lpoptions_custom_template(choice_key); + continue; + } + if starred { + default = choice_key.to_string(); + } + if choice_list.len() < MAX_LPOPTIONS_CHOICES && choice_list.iter().all(|(c, _)| c != choice_key) { + choice_list.push((choice_key.to_string(), lpoptions_label(choice_label))); + } + } + if choice_list.is_empty() || options.len() >= MAX_LPOPTIONS_OPTIONS { + continue; + } + if default.is_empty() && !marked_template { + // No choice marked at all: the dialog needs something selected, and the listing has + // nothing better than the first one to offer. + default = choice_list.first().map(|(c, _)| c.clone()).unwrap_or_default(); + } + options.push(PrinterOption { key: key.to_string(), label: lpoptions_label(label), group: String::new(), choices: choice_list, default }); + } + options +} + /// The keys the macOS print dialog names a job's title and owner under. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] const JOB_NAME_KEY: &str = "com.apple.print.JobInfo.PMJobName"; @@ -271,7 +386,9 @@ pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> fn ppd_languages(lang: &str) -> Vec { let lang = lang.to_ascii_lowercase(); let mut parts = lang.split(['-', '_']).filter(|p| !p.is_empty()); - let Some(primary) = parts.next() else { return Vec::new() }; + let Some(primary) = parts.next() else { + return Vec::new(); + }; let region = match (primary, parts.next()) { ("zh", Some("hans")) => Some("CN".to_string()), ("zh", Some("hant")) => Some("TW".to_string()), @@ -303,7 +420,9 @@ pub(crate) fn localize(options: &mut [PrinterOption], ppd: &[u8], lang: &str) { let mut choices: HashMap<(String, String), String> = HashMap::new(); let mut groups: HashMap = HashMap::new(); for line in ppd.split(|b| *b == b'\n') { - let Some(text) = std::str::from_utf8(line).ok() else { continue }; + let Some(text) = std::str::from_utf8(line).ok() else { + continue; + }; let text = text.trim_end_matches('\r'); if let Some(rest) = text.strip_prefix("*OpenGroup:") { // Group label → keyword, since options carry their group's label. @@ -313,10 +432,18 @@ pub(crate) fn localize(options: &mut [PrinterOption], ppd: &[u8], lang: &str) { } continue; } - let Some(rest) = text.strip_prefix(prefix.as_str()) else { continue }; - let Some((head, _)) = rest.split_once(':') else { continue }; - let Some((main, spec)) = head.split_once(' ') else { continue }; - let Some((key, label)) = spec.split_once('/') else { continue }; + let Some(rest) = text.strip_prefix(prefix.as_str()) else { + continue; + }; + let Some((head, _)) = rest.split_once(':') else { + continue; + }; + let Some((main, spec)) = head.split_once(' ') else { + continue; + }; + let Some((key, label)) = spec.split_once('/') else { + continue; + }; let label = label.trim(); if label.is_empty() { continue; diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index da4aef06f0..00fcadb758 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -759,7 +759,9 @@ mod cups_options { /// The child process stands in for lpoptions, recording its real arguments. #[test] fn stand_in_for_lpoptions() { - let Some(record) = std::env::var_os(RECORD) else { return }; + let Some(record) = std::env::var_os(RECORD) else { + return; + }; let args: Vec = std::env::args().skip_while(|a| a != "--").skip(1).collect(); std::fs::write(std::path::Path::new(&record).join("args"), args.join("\n")).unwrap(); let answer = std::env::var(ANSWER).unwrap(); @@ -877,6 +879,13 @@ mod cups_options { long_queue.as_str(), long_queue_instance.as_str(), long_instance.as_str(), + // A destination cannot be an argument: `lpoptions` would take these as options. + "-Office", + "-E", + "-", + "--", + "Office/-l", + "Office/--", ] { assert!( spool::printer_options_via_within(destination, &dir, command(&dir, "InputSlot: *Auto"), LIMIT, "en").is_empty(), @@ -886,6 +895,120 @@ mod cups_options { } std::fs::remove_dir_all(dir).unwrap(); } + + /// #4699: when the queue's PPD is not readable, build the option list from `lpoptions -l` + /// instead of returning an empty dialog. + #[test] + fn unreadable_ppd_falls_back_to_lpoptions_list() { + let dir = folder(); + // No PPD file: the PPD path is unreadable, just as on Arch where cupsd keeps it at + // 0640 root:cups. + let lpoptions_out = "MediaType/Media Type: *Stationery PhotographicGlossy StationeryInkjet Com.brotherBp71\ncupsPrintQuality/cupsPrintQuality: Draft *Normal High\nPageSize/Page Size: A4 Letter\nDuplex/Duplex: *None DuplexNoTumble\nno colon on this line\nEmpty/Empty:\n"; + let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en"); + let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect(); + assert_eq!(keys, ["MediaType", "cupsPrintQuality"], "PageSize is handled by the dialog and must be skipped"); + + let media_type = options.iter().find(|o| o.key == "MediaType").unwrap(); + assert_eq!(media_type.label, "Media Type"); + assert_eq!(media_type.default, "Stationery"); + assert_eq!( + media_type.choices, + [ + ("Stationery".into(), "Stationery".into()), + ("PhotographicGlossy".into(), "PhotographicGlossy".into()), + ("StationeryInkjet".into(), "StationeryInkjet".into()), + ("Com.brotherBp71".into(), "Com.brotherBp71".into()), + ] + ); + + let quality = options.iter().find(|o| o.key == "cupsPrintQuality").unwrap(); + assert_eq!(quality.label, "cupsPrintQuality"); + assert_eq!(quality.default, "Normal"); + assert_eq!(quality.choices, [("Draft".into(), "Draft".into()), ("Normal".into(), "Normal".into()), ("High".into(), "High".into())]); + + // The starring decides the fallback default, wherever the starred choice sits. + let lpoptions_out = "MediaType/Media Type: Plain *Glossy Matte\n"; + let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en"); + assert_eq!(options.len(), 1); + assert_eq!(options[0].default, "Glossy", "the starred choice is the fallback default"); + assert_eq!(options[0].choices, [("Plain".into(), "Plain".into()), ("Glossy".into(), "Glossy".into()), ("Matte".into(), "Matte".into())]); + + // A PPD path that exists but cannot be opened at all, for a reason other than NotFound: + // ENOTDIR fails for root as well, where a 0o000 file would still be readable. + let blocker = dir.join("not-a-directory"); + std::fs::write(&blocker, b"x").unwrap(); + let options = spool::printer_options_via_within("Office", &blocker, command(&dir, lpoptions_out), LIMIT, "en"); + assert_eq!(options.len(), 1, "an unopenable PPD falls back as an unreadable one does"); + + // A readable PPD whose *OpenUI blocks yield nothing (driverless queues): the fallback still + // fills the list, and the PPD's translations apply to it. + std::fs::write(dir.join("Empty.ppd"), b"*PPD-Adobe: \"4.3\"\n*cupsLanguages: \"it\"\n*it.Translation MediaType/Tipo di supporto: \"\"\n") + .unwrap(); + let options = spool::printer_options_via_within("Empty", &dir, command(&dir, lpoptions_out), LIMIT, "it"); + assert_eq!(options.len(), 1, "a readable PPD without options falls back too"); + assert_eq!(options[0].label, "Tipo di supporto", "the readable PPD still translates the fallback list"); + + // A queue instance reaches lpoptions as the destination, and falls back the same way. + let options = spool::printer_options_via_within("Office/Instance", &dir, command(&dir, lpoptions_out), LIMIT, "en"); + assert_eq!(options.len(), 1); + let recorded = std::fs::read_to_string(dir.join("args")).unwrap(); + assert!(recorded.contains("Office/Instance"), "the instance is the destination: {recorded}"); + + std::fs::remove_dir_all(dir).unwrap(); + } + + /// CUPS prints a custom option's template instead of its choices — `Custom`, `Custom.PASSCODE`, + /// `Custom.STRING`, `Custom.WIDTHxHEIGHT`: the dialog has no field to collect the value, so + /// offering the template would send that literal text to the driver. + #[test] + fn custom_value_templates_are_not_offered() { + let dir = folder(); + let lpoptions_out = "Pin/Pin: *Custom.PASSCODE\nJobPassword/Job Password: 1234 *5678 Custom.PASSWORD\nScale/Scale: *Custom.REAL\nSolo/Solo: *Custom\nMediaType/Media Type: *Plain Custom.Glossy Matte\n"; + let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en"); + let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect(); + assert_eq!(keys, ["JobPassword", "MediaType"], "an option whose choices are all templates is dropped"); + + let password = options.iter().find(|o| o.key == "JobPassword").unwrap(); + assert_eq!(password.choices, [("1234".into(), "1234".into()), ("5678".into(), "5678".into())]); + assert_eq!(password.default, "5678", "a template is never a choice"); + + let media_type = options.iter().find(|o| o.key == "MediaType").unwrap(); + assert_eq!(media_type.choices, [("Plain".into(), "Plain".into()), ("Matte".into(), "Matte".into())]); + assert_eq!(media_type.default, "Plain"); + + // A starred template is never offered as a choice, and never replaced by an invented + // default: the printer keeps the template, so standing the first real choice in its + // place would show a value the job does not use. + let options = spool::printer_options_via_within("Office", &dir, command(&dir, "Pin/Pin: *Custom.PASSCODE 1234\n"), LIMIT, "en"); + assert_eq!(options.len(), 1); + assert_eq!(options[0].choices, [("1234".into(), "1234".into())]); + assert_eq!(options[0].default, "", "an uncollectable default is not a choice"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// `list_group()` prints `Keyword/Text:` and then bare choice keywords, so the text may carry a + /// colon while the choices cannot: the split belongs at the last one. + #[test] + fn an_option_text_may_contain_a_colon() { + let dir = folder(); + let lpoptions_out = "Media/Supporto: Tipo: *A B\n"; + let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en"); + assert_eq!(options.len(), 1); + assert_eq!(options[0].key, "Media"); + assert_eq!(options[0].label, "Supporto: Tipo", "the text keeps its colon"); + assert_eq!(options[0].choices, [("A".into(), "A".into()), ("B".into(), "B".into())], "no choice is invented from the text"); + assert_eq!(options[0].default, "A"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn the_listing_is_capped_the_way_the_ppd_is() { + let dir = folder(); + let listing: String = (0..500).map(|i| format!("Option{i}/Option {i}: *A B\n")).collect(); + let options = spool::printer_options_via_within("Office", &dir, command(&dir, &listing), LIMIT, "en"); + assert_eq!(options.len(), 400, "the fallback honours the same cap as parse_ppd"); + std::fs::remove_dir_all(dir).unwrap(); + } } #[test] @@ -985,10 +1108,26 @@ fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { ("A0", "iso_a0_841x1189mm"), ("4 x 6 in label (101.6 x 152.4 mm)", "w288h432"), ("US #10 Envelope (4.125 x 9.5 in)", "Env10"), + ("Folio (8.5 x 13 in)", "na_foolscap_8.5x13in"), + ("F4 (210 x 330 mm)", "om_folio_210x330mm"), ] ); } +/// #4777: Folio (8.5 × 13 in) and F4 (210 × 330 mm) presets are offered, fed by their +/// PWG 5101.1 media names (`na_foolscap_8.5x13in` and `om_folio_210x330mm`) and laid out at their +/// proper dimensions. +#[test] +fn folio_and_f4_paper_sizes_are_available() { + let folio = PAPERS.iter().position(|(name, _)| *name == "Folio (8.5 x 13 in)").expect("Folio present"); + assert_eq!(cups_media(folio), Some("na_foolscap_8.5x13in")); + assert_eq!(PAPERS[folio].1, (612.0, 936.0)); + + let f4 = PAPERS.iter().position(|(name, _)| *name == "F4 (210 x 330 mm)").expect("F4 present"); + assert_eq!(cups_media(f4), Some("om_folio_210x330mm")); + assert_eq!(PAPERS[f4].1, (595.28, 935.43)); +} + /// #2734: the US #10 envelope (4.125 × 9.5 in) is offered, fed by its PPD name and laid out at /// its own size. #[test] diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 3455029a0d..67604292bb 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -1,4 +1,5 @@ use super::*; +use pdfcraft_engine::print::names as print_names; /// [`PdfCraftApp::date_text`] for callers that already borrow other parts of the app. pub(crate) fn date_text(session: &Session, fmt: Option<&str>) -> Result { @@ -75,6 +76,19 @@ impl PdfCraftApp { /// Serialize the user's persistent state (recent files, theme). Local only. pub fn persist(&self) -> String { let trusted: Vec = self.session.trusted_certificates().iter().map(pdfcraft_engine::sign::x509::to_pem).collect(); + // The Print dialog's choices that describe how this computer prints (#2001), named as + // `doc_print` names them. Copies, pages, layout and orientation are one job's. + let print = serde_json::json!({ + "duplex": print_names::duplex(self.print_draft.duplex), + "grayscale": self.print_draft.grayscale, + "collate": self.print_draft.collate, + "size": print_names::size_mode(self.print_draft.size), + "custom_scale": self.print_draft.custom_scale, + "comments_forms": print_names::content(self.print_draft.content), + "binding": print_names::binding(self.print_draft.binding), + "as_image": self.print_draft.as_image, + "dpi": self.print_draft.dpi, + }); let mut v = serde_json::json!({ "side_panels": self.side_panels.to_json(self.left_open), "docking_right": self.right, @@ -173,6 +187,8 @@ impl PdfCraftApp { 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)); + // Also set outside json! above, which is at serde_json's macro recursion limit. + v["print"] = print; v.to_string() } @@ -309,6 +325,41 @@ impl PdfCraftApp { if let Some(closed) = v["right_panel_closed"].as_bool() { self.right_panel_closed = closed; } + // The Print dialog's remembered choices (#2001). Settings are untrusted: an unknown name + // or a scale outside the dialog's range keeps the default. The scale is read before the + // size mode, which carries it. + let print = &v["print"]; + let draft = &mut self.print_draft; + if let Some(duplex) = print["duplex"].as_str().and_then(print_names::parse_duplex) { + draft.duplex = duplex; + } + if let Some(on) = print["grayscale"].as_bool() { + draft.grayscale = on; + } + if let Some(on) = print["collate"].as_bool() { + draft.collate = on; + } + if let Some(scale) = print["custom_scale"].as_f64().filter(|s| print_ui::CUSTOM_SCALE.contains(s)) { + draft.custom_scale = scale; + } + if let Some(size) = print["size"].as_str().and_then(|name| print_names::parse_size_mode(name, draft.custom_scale)) { + draft.size = size; + } + if let Some(content) = print["comments_forms"].as_str().and_then(print_names::parse_content) { + draft.content = content; + } + // A booklet's binding side, as Acrobat keeps it (#2707). + if let Some(binding) = print["binding"].as_str().and_then(print_names::parse_binding) { + draft.binding = binding; + } + // Print as image and its resolution (#6392), so a printer that needs pictures keeps getting + // them; a resolution the dialog doesn't offer keeps the default. + if let Some(on) = print["as_image"].as_bool() { + draft.as_image = on; + } + if let Some(dpi) = print["dpi"].as_f64().filter(|d| print_ui::IMAGE_DPI.contains(d)) { + draft.dpi = dpi; + } // Scan & OCR choices (language, engine, strategy, output mode, preprocessing and skip // flags); unknown or malformed values keep the defaults. self.ocr_draft.apply_pref(&v["ocr"]); diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 8096384e43..edb29db3f5 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -15,7 +15,7 @@ mod body; mod draft; pub(crate) use body::body; -pub use draft::{Handling, PrintDraft, Which}; +pub use draft::{CUSTOM_SCALE, Handling, IMAGE_DPI, PrintDraft, Which}; /// The preview pane, and the paper inside it (12 pt inset on each side). const PREVIEW_W: f32 = 320.0; @@ -152,18 +152,43 @@ impl PdfCraftApp { let default = default_printer(&printers); let current = self.views[i].current; let selected: Vec = self.views[i].selected.iter().copied().collect(); - let keep = std::mem::take(&mut self.print_draft); + let mut keep = std::mem::take(&mut self.print_draft); + // Leaving Label gives back the paper it replaced (#3564), before the layout resets below. + if keep.handling == Handling::Label { + keep.set_handling(Handling::Size); + } // The printer picked last time, Save as PDF included, while it is still listed (#3537). let picked = keep.printer_picked && offered(keep.printer.as_deref(), &printers); let printer = if picked { keep.printer.clone() } else { default }; - // Picked pages are what Print is for (Acrobat's "Selected pages"); without any, a choice - // left over from another document falls back to the whole document. - let which = match (selected.is_empty(), keep.which) { - (false, _) => Which::Selected, - (true, Which::Selected | Which::Area) => Which::All, - (true, other) => other, - }; + // Picked pages are what Print is for (Acrobat's "Selected pages"); without any, the whole + // document. + let which = if selected.is_empty() { Which::All } else { Which::Selected }; + // Copies, pages, the layout with its own options and orientation are this job's, and start + // from the defaults each time the dialog opens: ISO 32000-2 Table 147 initialises them per + // document when the print dialog opens (NumCopies, "typically 1"; PrintPageRange). The + // choices that describe how this computer prints (two-sided, grayscale, collate, sizing, + // Comments & forms, a booklet's binding) carry over, and are remembered across restarts + // (#2001). + let fresh = PrintDraft::default(); self.print_draft = PrintDraft { + copies: fresh.copies, + range: fresh.range, + subset: fresh.subset, + reverse: fresh.reverse, + handling: fresh.handling, + per_sheet: fresh.per_sheet, + custom_grid: fresh.custom_grid, + cols: fresh.cols, + rows: fresh.rows, + order: fresh.order, + border: fresh.border, + auto_rotate: fresh.auto_rotate, + booklet_subset: fresh.booklet_subset, + poster_scale: fresh.poster_scale, + overlap: fresh.overlap, + cut_marks: fresh.cut_marks, + label_flip: fresh.label_flip, + orientation: fresh.orientation, crops: Vec::new(), crop_pages: None, printers, @@ -685,6 +710,18 @@ mod tests { } } + /// #5806: with Print as image, grayscale makes the pictures grey, so the job doesn't ask the + /// driver for it as well; a driver that keeps no grayscale setting then still prints. + #[test] + fn grey_pictures_dont_ask_the_driver_for_grayscale() { + let vector = PrintDraft { grayscale: true, ..PrintDraft::default() }; + assert!(vector.job("a.pdf").grayscale, "vector pages: the driver makes them grey"); + let pictures = PrintDraft { grayscale: true, as_image: true, ..PrintDraft::default() }; + assert!(!pictures.job("a.pdf").grayscale, "grey pictures: nothing for the driver to do"); + assert!(pictures.settings(1, &[]).unwrap().raster.is_some_and(|r| r.grayscale), "grey pictures: the pages are rendered grey"); + assert!(!PrintDraft::default().job("a.pdf").grayscale); + } + #[test] fn composed_sheets_keep_exact_scaling_and_the_dialogs_duplex_choice() { let draft = PrintDraft { duplex: Some(spool::Duplex::LongEdge), ..PrintDraft::default() }; diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 3ba2f2bbdd..ed79c14080 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -1,4 +1,4 @@ -use super::draft::{Handling, PrintDraft, Which}; +use super::draft::{CUSTOM_SCALE, Handling, PrintDraft, Which}; use super::{ Binding, BookletSubset, Color32, Content, Orientation, PAPERS, PREVIEW_BOX, PREVIEW_H, PREVIEW_W, PageOrder, Pos2, Rect, SizeMode, Stroke, Subset, Tokens, pos2, print, printer_label, spool, still_looking, theme, vec2, widgets, @@ -328,7 +328,7 @@ pub(crate) fn body( if ui.add(widgets::radio(custom, tl!("Custom scale:"))).clicked() { d.size = SizeMode::Custom(d.custom_scale); } - ui.add_enabled(custom, egui::DragValue::new(&mut d.custom_scale).range(1.0..=1000.0).suffix(" %")) + ui.add_enabled(custom, egui::DragValue::new(&mut d.custom_scale).range(CUSTOM_SCALE).suffix(" %")) .accessible_name(tl!("Custom scale")); }); } diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index a35f1bafbc..1ed0954fa5 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -21,6 +21,12 @@ pub enum Handling { Label, } +/// The percent a custom scale can be (the dialog's field and a saved setting). +pub const CUSTOM_SCALE: std::ops::RangeInclusive = 1.0..=1000.0; + +/// The resolutions the Print dialog offers for Print as image, in dots per inch. +pub const IMAGE_DPI: [f64; 3] = [150.0, 300.0, 600.0]; + #[derive(Clone, Debug, PartialEq)] pub struct PrintDraft { pub printers: Vec, @@ -235,12 +241,17 @@ impl PrintDraft { orientation: self.orientation, layout, content: self.content, - raster, + raster: self.raster(), crops: self.crops.clone(), area: None, }) } + /// Print as image: pictures at the chosen resolution, grey when grayscale is asked for. + fn raster(&self) -> Option { + self.as_image.then_some(print::Raster { dpi: self.dpi, grayscale: self.grayscale }) + } + fn cut_and_stack(&self) -> bool { self.handling == Handling::Multiple && self.order == PageOrder::CutStack } @@ -262,7 +273,7 @@ impl PrintDraft { self.duplex }, scaling: spool::Scaling::Exact, - grayscale: self.grayscale, + grayscale: print::driver_needs_grayscale(self.grayscale, self.raster()), title: title.to_string(), // Only for the printer the options were read from. options: match (&self.printer, &self.driver_options) { diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 0eae20289a..f293ab578f 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -869,7 +869,7 @@ fn paper_choice_survives_cancel_and_settings_restore() { h.get_by_label("A4").click(); h.run_steps(2); assert_eq!(h.state().print_draft.paper, a4); - // Only paper should be added to persistence; these choices still belong to this session. + // Paper persists; copies, pages and orientation belong to this job (grayscale is remembered, #2001). let draft = &mut h.state_mut().print_draft; draft.copies = 5; draft.which = pdfcraft_ui_egui::PrintWhich::Range; @@ -886,8 +886,10 @@ fn paper_choice_survives_cancel_and_settings_restore() { assert_eq!(json["print_paper"], "A4", "store the canonical name, not its position"); let mut app = PdfCraftApp::new(); app.restore(&saved); - let expected = pdfcraft_ui_egui::PrintDraft { paper: a4, ..Default::default() }; - assert_eq!(app.print_draft, expected, "no other print preference becomes persistent"); + // Grayscale is one of the preferences remembered across restarts (#2001); copies, pages and + // orientation are this job's and are not saved. + let expected = pdfcraft_ui_egui::PrintDraft { paper: a4, grayscale: true, ..Default::default() }; + assert_eq!(app.print_draft, expected, "only the paper and the remembered preferences persist"); let mut restarted = harness_with_settings(Some(saved)); assert!(restarted.state_mut().execute("print.dialog")); restarted.run_steps(3); @@ -1006,3 +1008,110 @@ fn a_booklet_decides_two_sided_itself() { assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_some()); assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::LongEdge), "back to the choice"); } + +/// An app restored from `saved` settings (if any), with a form open and its Print dialog shown. +fn print_opened_with(saved: Option<&str>) -> PdfCraftApp { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + if let Some(saved) = saved { + app.restore(saved); + } + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + app.open_print(); + app +} + +/// The choices that describe how an office prints (two-sided, grayscale, collate, page sizing, +/// comments & forms, a booklet's binding side, Print as image and its resolution) survive a +/// restart; the ones that describe one job (copies, pages, layout, orientation) start from the +/// defaults each time Print opens, in the same session too (ISO 32000-2 Table 147 initialises them +/// per document) (#2001, #6392). +#[test] +fn print_preferences_survive_a_restart_but_the_jobs_own_choices_reset() { + use pdfcraft_engine::print::{Binding, BookletSubset, Content, LABEL_PAPER, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool::Duplex}; + use pdfcraft_ui_egui::{PrintHandling, PrintWhich}; + let open = print_opened_with; + let mut app = open(None); + let d = &mut app.print_draft; + d.duplex = Some(Duplex::LongEdge); + d.grayscale = true; + d.collate = false; + d.size = SizeMode::Custom(62.5); + d.custom_scale = 62.5; + d.content = Content::Document; + d.binding = Binding::Right; + d.as_image = true; + d.dpi = 600.0; + d.copies = 5; + d.which = PrintWhich::Range; + d.range = "2-3".into(); + d.subset = Subset::Even; + d.reverse = true; + d.handling = PrintHandling::Booklet; + d.booklet_subset = BookletSubset::FrontOnly; + d.per_sheet = 16; + (d.custom_grid, d.cols, d.rows) = (true, 3, 5); + d.order = PageOrder::CutStack; + d.orientation = Orientation::Landscape; + let saved = app.persist(); + + // Opened again in the same session: this job's choices, the layout's own options included, + // start over; the rest carry on. + app.open_print(); + let d = &app.print_draft; + assert_eq!((d.copies, d.which, d.range.as_str(), d.subset, d.reverse), (1, PrintWhich::All, "", Subset::All, false)); + assert_eq!((d.handling, d.orientation), (PrintHandling::Size, Orientation::Auto)); + assert_eq!((d.booklet_subset, d.per_sheet, d.order), (BookletSubset::BothSides, 2, PageOrder::Horizontal)); + let fresh = pdfcraft_ui_egui::PrintDraft::default(); + assert_eq!((d.custom_grid, d.cols, d.rows), (false, fresh.cols, fresh.rows)); + assert_eq!((d.duplex, d.grayscale, d.collate, d.size, d.binding), (Some(Duplex::LongEdge), true, false, SizeMode::Custom(62.5), Binding::Right)); + + // A job printed on label stock: the next one is back on the paper Label replaced (#3564). + let a4 = PAPERS.iter().position(|(name, _)| *name == "A4").unwrap(); + app.print_draft.paper = a4; + app.print_draft.set_handling(PrintHandling::Label); + assert_eq!(app.print_draft.paper, LABEL_PAPER); + app.open_print(); + assert_eq!((app.print_draft.handling, app.print_draft.paper), (PrintHandling::Size, a4)); + + let again = open(Some(&saved)); + let d = &again.print_draft; + assert_eq!(d.duplex, Some(Duplex::LongEdge)); + assert!(d.grayscale); + assert!(!d.collate); + assert_eq!(d.size, SizeMode::Custom(62.5)); + assert_eq!(d.custom_scale, 62.5); + assert_eq!(d.content, Content::Document); + assert_eq!(d.binding, Binding::Right, "a booklet's binding side is kept (#2707)"); + assert!(d.as_image, "Print as image is kept, for a printer that needs pictures (#6392)"); + assert_eq!(d.dpi, 600.0, "and its resolution"); + assert_eq!(d.copies, 1, "copies are this job's"); + assert_eq!(d.which, PrintWhich::All, "the page choice is this job's"); + assert_eq!(d.range, ""); +} + +/// Settings are untrusted: an unknown name, a scale outside the dialog's range or a resolution it +/// doesn't offer keeps the default (#2001, #6392). +#[test] +fn print_preferences_that_dont_parse_keep_the_defaults() { + use pdfcraft_engine::print::{Binding, Content, SizeMode, spool::Duplex}; + let open = print_opened_with; + let garbage = r#"{"print": {"duplex": "sideways", "grayscale": "yes", "collate": null, "size": "custom", "custom_scale": 1e9, "comments_forms": 7, "binding": "up", "as_image": 1, "dpi": 72}}"#; + let d = open(Some(garbage)).print_draft; + assert!(!d.as_image); + assert_eq!(d.dpi, 300.0, "a resolution the dialog doesn't offer keeps the default"); + assert_eq!(d.duplex, None, "an unknown name keeps the printer's own default"); + assert!(!d.grayscale); + assert!(d.collate); + assert_eq!(d.custom_scale, 100.0, "a scale outside 1..=1000 keeps the default"); + assert_eq!(d.size, SizeMode::Custom(100.0), "custom is honoured with the default scale"); + assert_eq!(d.content, Content::DocumentAndMarkups); + assert_eq!(d.binding, Binding::Left); + let d = open(Some(r#"{"print": 5}"#)).print_draft; + assert_eq!(d.size, SizeMode::Fit); + // Leaving two-sided to the printer is remembered as such, over a different earlier choice. + let d = open(Some(r#"{"print": {"duplex": "printer"}}"#)).print_draft; + assert_eq!(d.duplex, None); + let d = open(Some(r#"{"print": {"duplex": "off"}}"#)).print_draft; + assert_eq!(d.duplex, Some(Duplex::Off)); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e67255b291..1ec445acca 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8189,8 +8189,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print", "printers"] -evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/workflows/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/workflows/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page", "crates/print/src/tests.rs::every_paper_reaches_the_spooler_by_a_name_of_its_size", "crates/automation/src/printing.rs::printing_to_a_printer_feeds_the_laid_out_paper"] -notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name; this change does not alter persistence of other print preferences. CUPS jobs from the dialog and doc_print ask for the chosen paper (media) in every size: PPD keywords for the common ones, PWG 5101.1 names for A6, A0–A2, ARCH and ANSI C–E, checked against a synthetic PPD with cupsfilter, not on a physical large-format printer (#3752). On macOS jobs still take the PPD's default paper tray until craft-print sends Auto Select (#3752)" +evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/workflows/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/workflows/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page", "crates/print/src/tests.rs::every_paper_reaches_the_spooler_by_a_name_of_its_size", "crates/automation/src/printing.rs::printing_to_a_printer_feeds_the_laid_out_paper", "crates/ui-egui/tests/it/print.rs::print_preferences_survive_a_restart_but_the_jobs_own_choices_reset"] +notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name. Two-sided (including the printer's own default), grayscale, collate, page sizing and Comments & forms are remembered across restarts (#2001); copies, pages, layout and orientation start from the defaults each time the dialog opens (ISO 32000-2 Table 147 initialises them per document). CUPS jobs from the dialog and doc_print ask for the chosen paper (media) in every size: PPD keywords for the common ones, PWG 5101.1 names for A6, A0–A2, ARCH and ANSI C–E, checked against a synthetic PPD with cupsfilter, not on a physical large-format printer (#3752). On macOS jobs still take the PPD's default paper tray until craft-print sends Auto Select (#3752). Not yet: the document's own NumCopies, PrintPageRange, Duplex and PrintScaling viewer preferences as the dialog's initial values." [[feature]] id = "print.printer-selection" @@ -8381,8 +8381,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/it/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/it/automation.rs::doc_print_as_image"] -notes = "asks the printer for monochrome (lp -o, and the Windows page settings as the job's); with Print as image the pictures themselves are rendered grey (Rec. 601 luma), so the print-ready PDF is grayscale too. Still missing against Acrobat: a grey conversion of the vector pages without rendering them." +evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/it/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/it/automation.rs::doc_print_as_image", "crates/automation/src/printing.rs::grey_pictures_dont_ask_the_driver_for_grayscale", "crates/ui-egui/src/workflows/print_ui.rs::grey_pictures_dont_ask_the_driver_for_grayscale"] +notes = "asks the printer for monochrome (lp -o, and the Windows page settings as the job's); with Print as image the pictures themselves are rendered grey (Rec. 601 luma), so the print-ready PDF is grayscale too and the printer isn't asked for it: a driver that keeps no grayscale setting (Microsoft Print to PDF) prints them (#5806). Still missing against Acrobat: a grey conversion of the vector pages without rendering them, so vector pages to such a driver are refused rather than printed in colour." [[feature]] id = "print.print-as-image"