mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
Merge upstream/main into pr/print-area
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b00a4be12a
15 files changed
+689
-55
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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." },
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user