Merge upstream/main into pr/print-area

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Osvaldo Duran MelendezandClaude Sonnet 5.5 committed 2026-11-09 18:53:16 -05:00
commit b00a4be12a
15 files changed
+689 -55

No files matched your search

+35 -20
View File
@@ -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<spool::Duplex>, paper: usize, sheets: &[print::Sheet]) -> Result<spool::Job> {
/// 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<spool::Duplex>,
paper: usize,
raster: Option<print::Raster>,
sheets: &[print::Sheet],
) -> Result<spool::Job> {
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<spool::Duple
collate: a.opt_bool("collate")?.unwrap_or(true),
duplex,
scaling: spool::Scaling::Exact,
grayscale: a.opt_bool("grayscale")?.unwrap_or(false),
grayscale: print::driver_needs_grayscale(a.opt_bool("grayscale")?.unwrap_or(false), raster),
title,
options: {
let mut options: Vec<(String, String)> = 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::<Vec<_>>();
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<print::Raster>| 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));
}
}
+1 -1
View File
@@ -776,7 +776,7 @@ pub fn tools() -> Vec<ToolDef> {
"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." },
}),
+11 -2
View File
@@ -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<Vec<u8>, 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();
+11
View File
@@ -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,
+113 -1
View File
@@ -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<Duplex>) -> &'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<Option<Duplex>> {
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<Content> {
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<SizeMode> {
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<Binding> {
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);
}
}
}
+13 -1
View File
@@ -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<Raster>) -> 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.
+136 -9
View File
@@ -164,10 +164,14 @@ pub(crate) fn printer_options_via_within(
) -> Vec<PrinterOption> {
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.<TYPE>` (`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<PrinterOption> {
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<String> {
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<String, String> = 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;
+140 -1
View File
@@ -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<String> = 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]
+51
View File
@@ -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<String, String> {
@@ -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<String> = 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"]);
+46 -9
View File
@@ -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<usize> = 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() };
@@ -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"));
});
}
+13 -2
View File
@@ -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<f64> = 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<spool::Printer>,
@@ -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<print::Raster> {
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) {
+112 -3
View File
@@ -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));
}