From a1ea2d376376d7efbffd3670b6202b7ee02deac9 Mon Sep 17 00:00:00 2001 From: eliets2 Date: Mon, 9 Nov 2026 19:54:53 +0200 Subject: [PATCH 1/5] Align stream decode cap with renderer at 256 MiB (#6585) Editor, export and redaction must not read or rewrite text that the page view and Find never see, which a mismatched cap allows on large streams. Lower MAX_DECODED to 256 MiB to match the renderer cap, so every Stream::decoded-based content reader stops at the same byte and oversized streams error instead of exposing later text. Test: decoded_stops_at_renderer_cap_of_256_mib failed before the change and passes after. Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com> --- crates/cos/src/object.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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(); From abb06c70f8caf6e444378003adc490ac8c9ef7ef Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:09:03 +0800 Subject: [PATCH 2/5] M10: the Print dialog remembers two-sided, grayscale, collate, sizing, comments & forms and Print as image (#2001, #6392) (#2103) * M10: the Print dialog remembers two-sided, grayscale, collate, sizing and comments & forms (#2001) Persist the choices that describe how this computer prints and restore them on the next launch, under the names doc_print takes; copies, pages, layout, orientation and the printer still start fresh for each job. Those names now live in one place, pdfcraft_print::names, used by the tool and the settings file. Settings are untrusted: an unknown name or a custom scale outside the dialog's 1..=1000 % keeps the default. Co-Authored-By: Claude Fable 5.1 * M10: the Print dialog also remembers a booklet's binding side (#2707) Acrobat keeps the booklet binding (left or right) between prints; PdfCraft started at Left every time. It is now kept with the other remembered print choices, named as doc_print names it, and an unknown saved value keeps the default. Co-Authored-By: Claude Opus 5.5 * M10: Print starts each job's copies, pages, layout and orientation afresh (#2001) CodeRabbit on #2103: the README promised that copies, pages, layout and orientation start fresh, but open_print carried them over through `..keep` within a session. ISO 32000-2 Table 147 initialises these per document when the print dialog opens (NumCopies "typically 1", PrintPageRange), so each opening now resets them to the defaults (Selected pages still win when thumbnails are picked); two-sided, grayscale, collate, sizing, Comments & forms, binding, the paper and the printer picked last carry over as before. Two-sided names now include "printer" (leave it to the printer, main's default since #2422), so that choice is remembered too. main's paper-persistence test expected grayscale not to persist; it is one of the remembered preferences. Co-Authored-By: Claude Opus 5.5 * M10: a new print job also resets its layout's own options and leaves Label (#2001) CodeRabbit on #2103: resetting the layout to Size kept pages per sheet, order, borders, booklet subset and the poster options through `..keep`, so choosing Multiple again brought back, say, a cut-and-stack order; and a job printed on Label went back to Size without giving back the paper Label replaced. open_print now leaves Label through set_handling (#3564) and resets the layout's own options with the rest of the job's choices; the binding and sizing stay remembered. The README now says what resets on each opening and what carries over. Co-Authored-By: Claude Opus 5.5 * M10: the Print dialog also remembers Print as image and its resolution (#6392) A printer that needs pictures keeps getting them after a restart: as_image and dpi are saved with the other print preferences and restored, a resolution the dialog doesn't offer keeping the default 300 dpi. The bad-settings checks move to their own test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: mtlcl410 --- ROADMAP.md | 1 + crates/automation/src/printing.rs | 16 +-- crates/print/README.md | 11 ++ crates/print/src/lib.rs | 112 +++++++++++++++++ crates/ui-egui/src/app/prefs.rs | 51 ++++++++ crates/ui-egui/src/workflows/print_ui.rs | 43 +++++-- crates/ui-egui/src/workflows/print_ui/body.rs | 4 +- .../ui-egui/src/workflows/print_ui/draft.rs | 6 + crates/ui-egui/tests/it/print.rs | 115 +++++++++++++++++- parity/acrobat-features.toml | 4 +- 10 files changed, 335 insertions(+), 28 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e20839c69b..831a0e9ca3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -527,6 +527,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 06f7005e27..dd3f949db2 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}; @@ -174,9 +174,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 +224,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 +239,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. 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..54ae81cbdc 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -45,3 +45,115 @@ 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/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 ffed67f50d..0629ffaeba 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::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, diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 690dc3142e..465aaffded 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, @@ -315,7 +315,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 27080a7329..d041bdaf7c 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -19,6 +19,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, 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 642268ef87..df2dae6f63 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" From dc9d120fc8758fc69c77f89ef8120b0dac3e0327 Mon Sep 17 00:00:00 2001 From: Yusuf MANTAM <128176424+YusufMntm@users.noreply.github.com> Date: Tue, 10 Nov 2026 02:08:47 +0300 Subject: [PATCH 3/5] feat(print): add Folio and F4 paper presets (#4777) (#4953) Add Folio (8.5 x 13 in / 612 x 936 pt) and F4 (210 x 330 mm / 595.28 x 935.43 pt) to the PAPERS preset table and map them to standard PWG 5101.1 media names (na_foolscap_8.5x13in and om_folio_210x330mm) in cups_media. Closes #4777. Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/print/src/settings.rs | 6 +++++- crates/print/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index ff860f5c65..1873f20ca8 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -199,7 +199,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)), @@ -221,6 +221,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. @@ -255,6 +257,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/tests.rs b/crates/print/src/tests.rs index 0cd844a53d..b206822e32 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -985,10 +985,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] From efd7c9fc245d1660388208a37df3d3b5036584ba Mon Sep 17 00:00:00 2001 From: lordwotton29 <57874567+lordwotton29@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:24:22 +0100 Subject: [PATCH 4/5] fix(print): fall back to the lpoptions listing when the PPD is unreadable (#4699) (#4703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(print): fall back to the lpoptions listing when the PPD is unreadable cupsd keeps /etc/cups/ppd/.ppd at 0640 root:cups on Arch and similar setups, so an ordinary desktop user cannot open it. The option reader treated that permission error as "no options", and Print ▸ Properties… said "This printer offers no settings of its own" for a queue whose driver options cupsd was serving all along through lpoptions. When the PPD is missing, unreadable, or parses to no options, the list is now built from `lpoptions -p -l`. The options Craft handles itself are skipped, as the PPD parser does, and the starred choice is the fallback default. Fixes #4699 * fix(print): skip CUPS custom-value templates in the lpoptions fallback `lpoptions -l` prints `Custom`, `Custom.` (`Custom.PASSCODE`, `Custom.STRING`, …) or `Custom.WIDTHxHEIGHT` in place of the choices of an option whose value the user has to type. Nothing in the dialog collects that value, so offering the template sent its literal text to the driver. Those entries are now dropped, and a starred template never becomes the default. The choice cap is also tested before the duplicate scan, so a listing past `MAX_LPOPTIONS_CHOICES` no longer walks the stored choices on every token. Refs #4699 * fix(print): refuse a destination that starts with a dash `lpoptions` parses its argv with getopt, so `-p -E -l` reads `-E` as an option rather than as the destination; `--` cannot help, because it ends option parsing before the `-p` that names the queue. Reject a leading `-` on the queue and on the instance component beside the existing name checks, before the PPD is opened or the command is spawned. * fix(print): read the listing by its last colon, and stop inventing a default `list_group()` prints the option as `"%s/%s:"` from the PPD's keyword and translated text and each choice as a bare `" %s"`, so a colon can sit in the text but not in a choice keyword. Splitting at the first colon turned the rest of the text ("Supporto: Tipo") into invented choices; split at the last one instead. When the choice CUPS marks is a custom-value template, the parser dropped it and stood the first retained choice in as the default. The UI then showed a value the job does not use, because only options changed away from the default are submitted. Leave the default empty in that case: there is nothing the dialog could collect, and an invented choice is worse than no selection. --------- Co-authored-by: Jarvis (Hermes) Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/print/src/spool.rs | 145 +++++++++++++++++++++++++++++++++++--- crates/print/src/tests.rs | 125 +++++++++++++++++++++++++++++++- 2 files changed, 260 insertions(+), 10 deletions(-) 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 b206822e32..e8b48650cf 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] From 0c0f0b6b56ba2e444fd691197403d83a5836b72b Mon Sep 17 00:00:00 2001 From: austin62jake Date: Mon, 9 Nov 2026 17:43:00 -0600 Subject: [PATCH 5/5] M10: grayscale with Print as image prints on a driver that keeps no grayscale (#5806) (#5882) * M10: grayscale with Print as image prints on a driver that keeps no grayscale (#5806) With Print as image and grayscale, PdfCraft renders the pages as grey pictures itself, yet the job still asked the driver for grayscale. A driver that keeps no grayscale setting, Microsoft Print to PDF among them, made craft-print refuse the job ("the printer's driver did not retain the requested grayscale; nothing was sent"), so even the grey pictures never printed. The job now asks the driver for grayscale only when the pages aren't grey pictures already (print::driver_needs_grayscale), in the dialog and in doc_print. The print.grayscale parity note and the doc_print as_image description say so, and the note names what is still missing: vector pages to such a driver are refused rather than printed in colour. Co-Authored-By: Claude Opus 5.5 * M10: the grey-pictures test checks that the pictures are grey too (#5806) The test checked that grey pictures don't ask the driver for grayscale, but not that the pictures are grey. A change that dropped grayscale from the dialog's raster settings, while the job still skipped the driver, would have printed in colour with the test passing. It now asserts both. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/automation/src/printing.rs | 39 +++++++++++++++---- crates/automation/src/tools.rs | 2 +- crates/print/src/lib.rs | 2 +- crates/print/src/settings.rs | 8 ++++ crates/ui-egui/src/workflows/print_ui.rs | 12 ++++++ .../ui-egui/src/workflows/print_ui/draft.rs | 9 ++++- parity/acrobat-features.toml | 4 +- 7 files changed, 62 insertions(+), 14 deletions(-) diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index dd3f949db2..0af923ec25 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -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(); @@ -266,7 +275,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\")")), @@ -312,7 +321,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())] }; @@ -329,7 +338,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. @@ -345,7 +354,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 e1320972b8..46a3368a13 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -775,7 +775,7 @@ pub fn tools() -> Vec { "collate": { "type": "boolean" }, "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" }, - "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/print/src/lib.rs b/crates/print/src/lib.rs index 54ae81cbdc..fca266b870 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -38,7 +38,7 @@ 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; diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index 1873f20ca8..a182948f57 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -178,6 +178,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], diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 0629ffaeba..c30e76a3ba 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -709,6 +709,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/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index d041bdaf7c..bbba844e0b 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -216,11 +216,16 @@ impl PrintDraft { orientation: self.orientation, layout, content: self.content, - raster: self.as_image.then_some(print::Raster { dpi: self.dpi, grayscale: self.grayscale }), + raster: self.raster(), crops: self.crops.clone(), }) } + /// 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 } @@ -242,7 +247,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/parity/acrobat-features.toml b/parity/acrobat-features.toml index df2dae6f63..9701ca5f5d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8369,8 +8369,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"