From 2aa29254daeb8e179be97a160ca12de8165b686e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 00:40:57 -0400 Subject: [PATCH] =?UTF-8?q?M10.5:=20Print:=20new=20print=20crate=20(L4)=20?= =?UTF-8?q?imposes=20the=20pages=20to=20print=20onto=20sheets=20(Fit/Actua?= =?UTF-8?q?l/Shrink/Custom,=20Multiple=20pages=20per=20sheet=20with=20orde?= =?UTF-8?q?r,=20borders=20and=20auto-rotate,=20Booklet,=20Poster=20with=20?= =?UTF-8?q?overlap=20and=20cut=20marks,=20orientation,=20Comments=20&=20Fo?= =?UTF-8?q?rms=20honouring=20the=20Print=20flag)=20as=20a=20fresh=20print-?= =?UTF-8?q?ready=20PDF;=20page=20ranges=20with=20labels,=20odd/even,=20rev?= =?UTF-8?q?erse;=20CUPS=20spooler=20(printers,=20copies,=20collation,=20du?= =?UTF-8?q?plex,=20monochrome).=20Print=20dialog=20(=E2=8C=98P)=20with=20a?= =?UTF-8?q?=20live=20sheet=20preview=20from=20page=20thumbnails=20and=20Sa?= =?UTF-8?q?ve=20as=20PDF;=20print=20permission=20enforced;=20printers/doc?= =?UTF-8?q?=5Fprint=20tools=20(61=20tools)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 + Cargo.toml | 1 + crates/automation/src/lib.rs | 3 + crates/automation/src/printing.rs | 125 ++++++ crates/automation/src/tools.rs | 34 ++ crates/automation/tests/automation.rs | 17 + crates/engine/Cargo.toml | 1 + crates/engine/src/commands.rs | 1 + crates/engine/src/lib.rs | 22 + crates/engine/src/tests.rs | 25 ++ crates/print/Cargo.toml | 15 + crates/print/README.md | 32 ++ crates/print/src/lib.rs | 590 ++++++++++++++++++++++++++ crates/print/src/range.rs | 65 +++ crates/print/src/spool.rs | 111 +++++ crates/print/src/tests.rs | 193 +++++++++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/canvas.rs | 7 +- crates/ui-egui/src/chrome.rs | 2 +- crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 25 +- crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/print_ui.rs | 488 +++++++++++++++++++++ crates/ui-egui/tests/print.rs | 52 +++ parity/acrobat-features.toml | 146 +++---- 25 files changed, 1899 insertions(+), 76 deletions(-) create mode 100644 crates/automation/src/printing.rs create mode 100644 crates/print/Cargo.toml create mode 100644 crates/print/README.md create mode 100644 crates/print/src/lib.rs create mode 100644 crates/print/src/range.rs create mode 100644 crates/print/src/spool.rs create mode 100644 crates/print/src/tests.rs create mode 100644 crates/ui-egui/src/print_ui.rs create mode 100644 crates/ui-egui/tests/print.rs diff --git a/Cargo.lock b/Cargo.lock index 4c58add34..8802af85e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3127,6 +3127,7 @@ dependencies = [ "printcraft-forms", "printcraft-model", "printcraft-organize", + "printcraft-print", "printcraft-redact", "printcraft-render", "thiserror 2.0.21", @@ -3176,6 +3177,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "printcraft-print" +version = "0.1.0" +dependencies = [ + "printcraft-content", + "printcraft-cos", + "printcraft-model", + "thiserror 2.0.21", +] + [[package]] name = "printcraft-redact" version = "0.1.0" @@ -3219,6 +3230,7 @@ dependencies = [ "png 0.18.1", "printcraft-cos", "printcraft-engine", + "printcraft-model", "printcraft-render", "rfd", "serde", diff --git a/Cargo.toml b/Cargo.toml index 88ba6169a..4d43991b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ printcraft-fonts = { path = "crates/fonts", version = "0.1.0" } printcraft-model = { path = "crates/model", version = "0.1.0" } printcraft-content = { path = "crates/content", version = "0.1.0" } printcraft-redact = { path = "crates/redact", version = "0.1.0" } +printcraft-print = { path = "crates/print", version = "0.1.0" } printcraft-edit = { path = "crates/edit", version = "0.1.0" } printcraft-create = { path = "crates/create", version = "0.1.0" } printcraft-forms = { path = "crates/forms", version = "0.1.0" } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 53c16467b..066799012 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -13,6 +13,7 @@ mod comments; mod forms; #[cfg(feature = "mcp")] pub mod mcp; +mod printing; mod redact; mod tools; @@ -261,6 +262,8 @@ impl Automation { "redact_apply" => self.redact_apply(&a)?, "redact_clear" => self.redact_clear(&a)?, "doc_hidden_info" => self.doc_hidden_info(&a)?, + "printers" => self.printers()?, + "doc_print" => self.doc_print(&a)?, "doc_remove_hidden" => self.doc_remove_hidden(&a)?, "fill_sign_add" => self.fill_sign_add(&a)?, "comment_list" => self.comment_list(&a)?, diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs new file mode 100644 index 000000000..a56e8ca62 --- /dev/null +++ b/crates/automation/src/printing.rs @@ -0,0 +1,125 @@ +//! Printing tools: list printers, and print (or save the print-ready PDF) with Acrobat's Print +//! dialog options. + +use printcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool}; +use serde_json::{Value, json}; + +use crate::{Args, Automation, Result, ToolError, failed, write_atomic}; + +fn bad(m: impl Into) -> ToolError { + ToolError::InvalidArgs(m.into()) +} + +impl Automation { + pub(crate) fn printers(&self) -> Result { + let list: Vec = spool::printers().into_iter().map(|p| json!({ "name": p.name, "default": p.default })).collect(); + Ok(json!({ "count": list.len(), "printers": list })) + } + + pub(crate) fn doc_print(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let (id, count) = (doc.id, doc.info.pages.len()); + let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect(); + let subset = match a.opt_str("subset")?.unwrap_or("all") { + "all" => Subset::All, + "odd" => Subset::Odd, + "even" => Subset::Even, + s => return Err(bad(format!("unknown subset {s:?}"))), + }; + 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" => { + let per = a.opt_int("per_sheet")?.unwrap_or(2).clamp(1, 256) as usize; + let order = match a.opt_str("order")?.unwrap_or("horizontal") { + "horizontal" => PageOrder::Horizontal, + "horizontal-reversed" => PageOrder::HorizontalReversed, + "vertical" => PageOrder::Vertical, + "vertical-reversed" => PageOrder::VerticalReversed, + o => return Err(bad(format!("unknown order {o:?}"))), + }; + match Layout::multiple(per) { + Layout::Multiple { cols, rows, .. } => Layout::Multiple { + cols, + rows, + order, + border: a.opt_bool("border")?.unwrap_or(false), + auto_rotate: a.opt_bool("auto_rotate")?.unwrap_or(true), + }, + l => l, + } + } + "booklet" => Layout::Booklet { + subset: match a.opt_str("booklet_subset")?.unwrap_or("both") { + "both" => BookletSubset::BothSides, + "front" => BookletSubset::FrontOnly, + "back" => BookletSubset::BackOnly, + s => return Err(bad(format!("unknown booklet_subset {s:?}"))), + }, + binding: if a.opt_str("binding")? == Some("right") { Binding::Right } else { Binding::Left }, + }, + "poster" => Layout::Poster { + scale: a.opt_num("scale")?.unwrap_or(200.0), + 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:?}"))), + }; + let orientation = match a.opt_str("orientation")?.unwrap_or("auto") { + "auto" => Orientation::Auto, + "portrait" => Orientation::Portrait, + "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 paper = match a.opt_str("paper")? { + None => PAPERS[0].1, + Some(p) => PAPERS + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case(p) || n.replace("US ", "").eq_ignore_ascii_case(p)) + .map(|x| x.1) + .ok_or_else(|| bad(format!("unknown paper {p:?} (Letter, Legal, Tabloid, A3, A4, A5)")))?, + }; + let settings = print::Settings { pages, paper, orientation, layout, content }; + let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); + let sheets = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?.len(); + let bytes = self.session.print_pdf(id, &settings).map_err(failed)?; + let mut out = json!({ "sheets": sheets, "pages": settings.pages.len(), "bytes": bytes.len() }); + match (a.opt_str("path")?, a.opt_str("printer")?) { + (Some(p), None) => { + let path = self.resolve(p, true)?; + write_atomic(&path, &bytes)?; + out["path"] = json!(path.to_string_lossy()); + } + (None, Some(printer)) => { + let name = self.doc(a)?.name.clone(); + let job = spool::Job { + printer: if printer == "default" { None } else { Some(printer.to_string()) }, + copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32, + collate: a.opt_bool("collate")?.unwrap_or(true), + duplex: match a.opt_str("duplex")?.unwrap_or("off") { + "off" => spool::Duplex::Off, + "long-edge" => spool::Duplex::LongEdge, + "short-edge" => spool::Duplex::ShortEdge, + d => return Err(bad(format!("unknown duplex {d:?}"))), + }, + grayscale: a.opt_bool("grayscale")?.unwrap_or(false), + title: name, + }; + 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\")")), + } + Ok(out) + } +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index f0b71c395..cafc55baa 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -307,6 +307,40 @@ pub fn tools() -> Vec { }), &["doc"], )), + t("printers", "List printers", "The printers the system's print spooler knows (CUPS on macOS and Linux), with the default marked.").ro().with(schema(json!({}), &[])), + t( + "doc_print", + "Print", + "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid/A3/A4/A5. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale.", + ) + .with(schema( + json!({ + "doc": doc(), + "pages": { "type": "string" }, + "subset": { "type": "string", "enum": ["all", "odd", "even"] }, + "reverse": { "type": "boolean" }, + "layout": { "type": "string", "enum": ["fit", "actual", "shrink", "custom", "multiple", "booklet", "poster"] }, + "scale": { "type": "number", "exclusiveMinimum": 0 }, + "per_sheet": { "type": "integer", "minimum": 1, "maximum": 256 }, + "order": { "type": "string", "enum": ["horizontal", "horizontal-reversed", "vertical", "vertical-reversed"] }, + "border": { "type": "boolean" }, + "auto_rotate": { "type": "boolean" }, + "booklet_subset": { "type": "string", "enum": ["both", "front", "back"] }, + "binding": { "type": "string", "enum": ["left", "right"] }, + "overlap": { "type": "number", "minimum": 0 }, + "cut_marks": { "type": "boolean" }, + "orientation": { "type": "string", "enum": ["auto", "portrait", "landscape"] }, + "comments_forms": { "type": "string", "enum": ["document", "document-and-markups", "document-and-stamps", "form-fields-only"] }, + "paper": { "type": "string" }, + "path": { "type": "string" }, + "printer": { "type": "string" }, + "copies": { "type": "integer", "minimum": 1, "maximum": 999 }, + "collate": { "type": "boolean" }, + "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge"] }, + "grayscale": { "type": "boolean" }, + }), + &["doc"], + )), t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status and replies.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 561037fb2..4ca9e595d 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -662,3 +662,20 @@ fn removing_hidden_information_through_tools() { assert!(!bytes.windows(11).any(|w| w == b"Secret plan"), "the old revision is gone"); assert!(matches!(a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["nonsense"] })), Err(ToolError::InvalidArgs(_)))); } + +#[test] +fn printing_through_tools() { + let dir = workdir("print"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert!(ok(&mut a, "printers", json!({}))["printers"].is_array()); + let n = ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]["pages"].as_u64().unwrap(); + let r = ok(&mut a, "doc_print", json!({ "doc": doc, "layout": "multiple", "per_sheet": 4, "path": "sheets.pdf" })); + assert_eq!(r["sheets"].as_u64(), Some(n.div_ceil(4))); + let printed = ok(&mut a, "doc_open", json!({ "path": "sheets.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": printed }))["document"]["pages"].as_u64(), Some(n.div_ceil(4))); + let r = ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "layout": "poster", "scale": 400, "path": "poster.pdf" })); + assert!(r["sheets"].as_u64().unwrap() > 1); + assert!(matches!(a.call("doc_print", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("doc_print", &json!({ "doc": doc, "pages": "99", "path": "x.pdf" })), Err(ToolError::InvalidArgs(_)))); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index e1c9e4b77..42c2a1778 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -16,6 +16,7 @@ printcraft-annot = { workspace = true } printcraft-forms = { workspace = true } printcraft-edit = { workspace = true } printcraft-redact = { workspace = true } +printcraft-print = { workspace = true } printcraft-create = { workspace = true } printcraft-model = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 26b67e569..194becd5a 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -153,6 +153,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("file.save", "Save", FILE, Some(Shortcut::cmd("S")), Document, "save"), c("file.save_as", "Save as…", FILE, Some(Shortcut::cmd_shift("S")), Document, "save"), c("file.close", "Close file", FILE, Some(Shortcut::cmd("W")), Document, "x"), + c("print.dialog", "Print…", FILE, Some(Shortcut::cmd("P")), Document, "printer"), c("file.properties", "Document properties…", FILE, Some(Shortcut::cmd("D")), Document, "info"), ct("edit.undo", "Undo", EDIT, Some(Shortcut::cmd("Z")), Undo, "undo-2"), ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6da3716d1..1b7d069e6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -29,6 +29,7 @@ pub use printcraft_forms::{ /// Comment geometry helpers (text-box line breaking) for frontends. pub use printcraft_annot::appearance as annot_text; pub use printcraft_annot::{FillMark, Markup, NewAnnotation, NoteIcon, Props as CommentProps, ReviewState, Rgb, Shape, Style, rect_quad}; +pub use printcraft_print as print; pub use printcraft_redact::patterns::{PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern}; pub use printcraft_redact::sanitize::{HIDDEN, Hidden}; @@ -143,6 +144,11 @@ impl Document { self.editor.as_ref().and_then(|e| e.cos.permissions()) } + /// Printing is allowed (Table 22, bit 3). + pub fn allows_printing(&self) -> bool { + self.permissions().is_none_or(|p| p.print()) + } + /// Page changes (insert, delete, rotate, move, extract) are allowed. pub fn allows_assembly(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| p.assemble()) @@ -1008,6 +1014,8 @@ pub enum EditError { #[error(transparent)] Redact(#[from] printcraft_redact::RedactError), #[error("{0}")] + Print(String), + #[error("{0}")] Edit(#[from] printcraft_edit::EditError), #[error("{0}")] Create(#[from] printcraft_create::CreateError), @@ -1328,6 +1336,20 @@ impl Session { Ok((Arc::new(bytes), merged)) } + /// The print-ready PDF for `settings` (sheets laid out for the paper; see `printcraft-print`). + pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if !doc.allows_printing() { + return Err(EditError::NotPermitted("printing")); + } + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => printcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) + .map_err(|e| EditError::Write(e.to_string()))?, + }; + printcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) + } + /// Combine whole files, in order, into new PDF bytes (one bookmark per file). pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 232846f68..319c1998c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -718,6 +718,31 @@ fn redaction_marks_apply_for_good_and_undo() { assert!(matches!(s.apply(id, Edit::ApplyRedactions { pages: None }), Err(EditError::Redact(_)))); } +#[test] +fn printing_lays_out_sheets_that_render() { + let (s, id) = session_with(5); + let settings = print::Settings { + pages: print::select_pages(5, Some("2-5"), &[], print::Subset::All, false).unwrap(), + layout: print::Layout::multiple(2), + ..Default::default() + }; + let bytes = s.print_pdf(id, &settings).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("print.pdf", None, Arc::new(bytes), None).unwrap(); + assert_eq!(s2.get(id2).unwrap().info.pages.len(), 2, "four pages, two per sheet"); + let texts = page_texts(&s2, id2); + assert!(texts[0].contains("Page 2") && texts[0].contains("Page 3") && texts[1].contains("Page 5"), "{texts:?}"); + // Printing honours the permissions. + let p = + Protection { open_password: Some("pw".into()), permissions_password: Some("owner".into()), printing: Printing::None, ..Default::default() }; + let (mut s3, id3) = session_with(1); + s3.apply(id3, Edit::Protect(p)).unwrap(); + let saved = s3.save_bytes(id3).unwrap(); + let mut s4 = Session::new(); + let id4 = s4.open("locked.pdf", None, saved, Some("pw")).unwrap(); + assert_eq!(s4.print_pdf(id4, &print::Settings { pages: vec![0], ..Default::default() }), Err(EditError::NotPermitted("printing"))); +} + #[test] fn crop_and_duplicate_pages_show_in_the_viewer() { let (mut s, id) = session_with(2); diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml new file mode 100644 index 000000000..aca8388e5 --- /dev/null +++ b/crates/print/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "printcraft-print" +description = "Printing: page selection, sizing (fit, actual, shrink, custom), multiple pages per sheet, booklets, posters, comments & forms; print-ready PDF and the system spooler" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +printcraft-cos = { workspace = true } +printcraft-content = { workspace = true } +printcraft-model = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/print/README.md b/crates/print/README.md new file mode 100644 index 000000000..abadfbc0f --- /dev/null +++ b/crates/print/README.md @@ -0,0 +1,32 @@ +# printcraft-print + +Layer L4: printing (execution plan M10.5). + +```rust +let pages = select_pages(count, Some("1-3, ii, 9-"), &labels, Subset::All, false)?; +let settings = Settings { pages, paper: PAPERS[0].1, layout: Layout::multiple(4), ..Default::default() }; +let sheets = layout(&display_sizes, &settings)?; // geometry only (previews) +let pdf = impose(&doc, &settings)?; // the print-ready PDF +spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default() })?; +``` + +- **Size**: fit (to the sheet minus an 18 pt margin), actual size, shrink oversized, custom %; + centred; auto orientation turns the sheet for landscape pages. +- **Multiple**: 2/4/6/9/16 (or any n) pages per sheet, horizontal/vertical (reversed) order, + page borders, auto-rotation of pages that don't match the cell. +- **Booklet**: saddle-stitch imposition padded to a multiple of 4; both sides, front or back + only; left or right binding. +- **Poster**: tile scale, overlap shared by neighbouring tiles, cut marks. +- **Comments & forms**: document, + markups, + stamps, or form fields only; annotations print + only with their Print flag, drawn from their appearance streams (Algorithm 8.1). + +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, +garbage-collected file (callers check the print permission). + +`spool` talks to CUPS (`lpstat -p -d`, `lp` with copies, collation, duplex and monochrome +options). Other platforms report that printing to a printer isn't available yet; the +print-ready PDF can always be saved. + +Not yet: Windows and web spoolers, print as image, poster labels, PostScript output, colour +conversion for grayscale. diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs new file mode 100644 index 000000000..ad94ea090 --- /dev/null +++ b/crates/print/src/lib.rs @@ -0,0 +1,590 @@ +//! Printing (execution plan M10.5). Layer L4. +//! +//! [`impose`] turns the pages to print into a print-ready PDF of *sheets*, laid out the way +//! Acrobat's Print dialog describes them: +//! - **Size**: one page per sheet, Fit, Actual size, Shrink oversized pages or a custom scale, +//! centred; +//! - **Multiple**: pages per sheet in a grid (2, 4, 6, 9, 16 or custom), four page orders, +//! optional page borders and auto-rotation; +//! - **Booklet**: saddle-stitched spreads (both sides, front or back only; left or right +//! binding); +//! - **Poster**: each page enlarged across several sheets with overlap and cut marks; +//! - orientation (auto, portrait, landscape) and what to print (the document, with markups, +//! with stamps, or form fields only; annotations print only with their Print flag). +//! +//! The sheets are written as a new, unencrypted, garbage-collected PDF; [`spool`] hands it to +//! the system's print spooler (CUPS on macOS and Linux). + +use printcraft_content::Matrix; +use printcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + +pub mod range; +pub mod spool; +#[cfg(test)] +mod tests; + +pub use range::{Subset, select_pages}; + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum PrintError { + #[error("there are no pages to print")] + NoPages, + #[error("{0}")] + Invalid(String), + #[error("{0}")] + Spool(String), + #[error(transparent)] + Cos(#[from] printcraft_cos::CosError), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Orientation { + #[default] + Auto, + Portrait, + Landscape, +} + +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum SizeMode { + #[default] + Fit, + Actual, + Shrink, + /// Percent. + Custom(f64), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageOrder { + #[default] + Horizontal, + HorizontalReversed, + Vertical, + VerticalReversed, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum BookletSubset { + #[default] + BothSides, + FrontOnly, + BackOnly, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Binding { + #[default] + Left, + Right, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Layout { + Size(SizeMode), + Multiple { + cols: usize, + rows: usize, + order: PageOrder, + border: bool, + auto_rotate: bool, + }, + Booklet { + subset: BookletSubset, + binding: Binding, + }, + /// Tile scale (percent), overlap (points), cut marks. + Poster { + scale: f64, + overlap: f64, + cut_marks: bool, + }, +} + +impl Default for Layout { + fn default() -> Self { + Layout::Size(SizeMode::Fit) + } +} + +impl Layout { + /// Acrobat's pages-per-sheet choices as a grid (columns × rows on a portrait sheet). + pub fn multiple(per_sheet: usize) -> Layout { + let (cols, rows) = match per_sheet { + 2 => (1, 2), + 4 => (2, 2), + 6 => (2, 3), + 9 => (3, 3), + 16 => (4, 4), + n => { + let c = (n as f64).sqrt().ceil().max(1.0) as usize; + (c, n.div_ceil(c).max(1)) + } + }; + Layout::Multiple { cols, rows, order: PageOrder::Horizontal, border: false, auto_rotate: true } + } +} + +/// Acrobat's Comments & Forms choices. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Content { + /// Page content and form fields. + Document, + /// Page content, form fields and every printable comment. + #[default] + DocumentAndMarkups, + /// Page content, form fields and stamps. + DocumentAndStamps, + /// Form fields only. + FormFieldsOnly, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Settings { + /// Source pages (0-based) in print order; see [`select_pages`]. + pub pages: Vec, + /// Paper (width, height) in points, portrait. + pub paper: (f64, f64), + pub orientation: Orientation, + pub layout: Layout, + pub content: Content, +} + +impl Default for Settings { + fn default() -> Self { + Settings { pages: Vec::new(), paper: PAPERS[0].1, orientation: Orientation::Auto, layout: Layout::default(), content: Content::default() } + } +} + +/// Common paper sizes (points, portrait). +pub const PAPERS: [(&str, (f64, f64)); 6] = [ + ("US Letter", (612.0, 792.0)), + ("US Legal", (612.0, 1008.0)), + ("Tabloid", (792.0, 1224.0)), + ("A3", (841.89, 1190.55)), + ("A4", (595.28, 841.89)), + ("A5", (419.53, 595.28)), +]; + +/// The unprintable margin assumed around the sheet for Fit, Multiple and Booklet. +pub const MARGIN: f64 = 18.0; + +/// One page as placed on a sheet: the source page, the matrix from its *display space* to the +/// sheet, and the visible part of it in display space (clipping). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub page: usize, + pub matrix: Matrix, + pub clip: [f64; 4], +} + +/// One output sheet. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Sheet { + pub size: (f64, f64), + pub placed: Vec, + /// Page borders (Multiple) and cut marks (Poster), in sheet space: rectangles and lines. + pub borders: Vec<[f64; 4]>, + pub lines: Vec<[f64; 4]>, +} + +fn oriented(paper: (f64, f64), o: Orientation, landscape_wanted: bool) -> (f64, f64) { + let (w, h) = (paper.0.min(paper.1), paper.0.max(paper.1)); + match o { + Orientation::Portrait => (w, h), + Orientation::Landscape => (h, w), + Orientation::Auto => { + if landscape_wanted { + (h, w) + } else { + (w, h) + } + } + } +} + +/// Place a page of display size `(dw, dh)` inside `cell` (sheet space), scaled by `s` +/// (centred), optionally rotated 90° (counter-clockwise). +fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: bool) -> Placement { + let (cw, ch) = (cell[2] - cell[0], cell[3] - cell[1]); + let (pw, ph) = if rotate { (dh * s, dw * s) } else { (dw * s, dh * s) }; + let (x, y) = (cell[0] + (cw - pw) / 2.0, cell[1] + (ch - ph) / 2.0); + let matrix = if rotate { Matrix([0.0, s, -s, 0.0, x + pw, y]) } else { Matrix([s, 0.0, 0.0, s, x, y]) }; + Placement { page, matrix, clip: [0.0, 0.0, dw, dh] } +} + +fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 { + if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) } +} + +/// Lay out the sheets for `settings` given each source page's display size. +pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, PrintError> { + let pages: Vec = settings.pages.iter().copied().filter(|p| *p < sizes.len()).collect(); + if pages.is_empty() { + return Err(PrintError::NoPages); + } + let paper = settings.paper; + if !(paper.0 >= 72.0 && paper.1 >= 72.0 && paper.0.is_finite() && paper.1.is_finite()) { + return Err(PrintError::Invalid("invalid paper size".into())); + } + let mut sheets = Vec::new(); + match settings.layout { + Layout::Size(mode) => { + for &p in &pages { + let d = sizes[p]; + let size = oriented(paper, settings.orientation, d.0 > d.1); + let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN)); + let s = match mode { + SizeMode::Fit => fit, + SizeMode::Actual => 1.0, + SizeMode::Shrink => fit.min(1.0), + SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0, + SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())), + }; + sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() }); + } + } + Layout::Multiple { cols, rows, order, border, auto_rotate } => { + if cols == 0 || rows == 0 || cols * rows > 256 { + return Err(PrintError::Invalid("invalid number of pages per sheet".into())); + } + // A grid with more columns than rows wants a landscape sheet, and the reverse. + let first = sizes[pages[0]]; + let page_landscape = first.0 > first.1; + let (gc, gr, sheet_landscape) = if cols == rows { + (cols, rows, page_landscape) + } else if settings.orientation == Orientation::Landscape || (settings.orientation == Orientation::Auto && cols < rows) { + // 2 per sheet (1 × 2 on portrait) prints side by side on landscape paper. + (rows, cols, true) + } else { + (cols, rows, false) + }; + let size = oriented(paper, settings.orientation, sheet_landscape); + let gap = 6.0; + let (aw, ah) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); + let (cw, ch) = ((aw - gap * (gc - 1) as f64) / gc as f64, (ah - gap * (gr - 1) as f64) / gr as f64); + for chunk in pages.chunks(gc * gr) { + let mut sheet = Sheet { size, ..Sheet::default() }; + for (k, &p) in chunk.iter().enumerate() { + let (col, row) = match order { + PageOrder::Horizontal => (k % gc, k / gc), + PageOrder::HorizontalReversed => (gc - 1 - k % gc, k / gc), + PageOrder::Vertical => (k / gr, k % gr), + PageOrder::VerticalReversed => (gc - 1 - k / gr, k % gr), + }; + let x0 = MARGIN + col as f64 * (cw + gap); + let y1 = size.1 - MARGIN - row as f64 * (ch + gap); + let cell = [x0, y1 - ch, x0 + cw, y1]; + let d = sizes[p]; + let rotate = auto_rotate && (d.0 > d.1) != (cw > ch) && (d.0 - d.1).abs() > 1.0; + let s = fit_scale(if rotate { (d.1, d.0) } else { d }, (cw, ch)); + let pl = place_in(p, d, cell, s, rotate); + if border { + let b = pl.matrix.bbox(pl.clip); + sheet.borders.push(b); + } + sheet.placed.push(pl); + } + sheets.push(sheet); + } + } + Layout::Booklet { subset, binding } => { + // Pad to a multiple of 4 with blank pages (None). + let n4 = pages.len().div_ceil(4) * 4; + let at = |i: usize| pages.get(i).copied(); + let size = oriented(paper, Orientation::Landscape, true); + let half = (size.0 - 2.0 * MARGIN) / 2.0; + let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]]; + for k in 0..n4 / 4 { + let front = (at(n4 - 1 - 2 * k), at(2 * k)); + let back = (at(2 * k + 1), at(n4 - 2 - 2 * k)); + let mut sides = Vec::new(); + if subset != BookletSubset::BackOnly { + sides.push(front); + } + if subset != BookletSubset::FrontOnly { + sides.push(back); + } + for (l, r) in sides { + let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) }; + let mut sheet = Sheet { size, ..Sheet::default() }; + for (p, cell) in [(l, cells[0]), (r, cells[1])] { + if let Some(p) = p { + let d = sizes[p]; + let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1])); + sheet.placed.push(place_in(p, d, cell, s, false)); + } + } + sheets.push(sheet); + } + } + } + Layout::Poster { scale, overlap, cut_marks } => { + if !(scale.is_finite() && scale > 0.0 && overlap.is_finite() && overlap >= 0.0) { + return Err(PrintError::Invalid("invalid tile scale or overlap".into())); + } + let s = scale / 100.0; + for &p in &pages { + let d = sizes[p]; + let size = oriented(paper, settings.orientation, d.0 > d.1); + // Each tile shows `step` of the scaled page plus `overlap` shared with the next. + let (tw, th) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); + if overlap * 2.0 >= tw.min(th) { + return Err(PrintError::Invalid("the overlap is larger than the tile".into())); + } + let (sw, sh) = (d.0 * s, d.1 * s); + let (stepx, stepy) = (tw - overlap, th - overlap); + let nx = (((sw - overlap) / stepx).ceil() as usize).max(1); + let ny = (((sh - overlap) / stepy).ceil() as usize).max(1); + if nx * ny > 1024 { + return Err(PrintError::Invalid("the poster would need more than 1024 sheets".into())); + } + // Tiles from the top-left, row by row. + for ty in 0..ny { + for tx in 0..nx { + // The window into the scaled page (scaled display space, y up). + let wx0 = tx as f64 * stepx; + let wy1 = sh - ty as f64 * stepy; + let matrix = Matrix([s, 0.0, 0.0, s, MARGIN - wx0, size.1 - MARGIN - wy1]); + let clip = [wx0 / s, (wy1 - th) / s, (wx0 + tw) / s, wy1 / s]; + let mut sheet = Sheet { size, placed: vec![Placement { page: p, matrix, clip }], ..Sheet::default() }; + if cut_marks { + let (x0, y0, x1, y1) = (MARGIN, MARGIN, size.0 - MARGIN, size.1 - MARGIN); + for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] { + let dx = if x == x0 { -1.0 } else { 1.0 }; + let dy = if y == y0 { -1.0 } else { 1.0 }; + sheet.lines.push([x + dx * 2.0, y, x + dx * (MARGIN - 2.0), y]); + sheet.lines.push([x, y + dy * 2.0, x, y + dy * (MARGIN - 2.0)]); + } + } + sheets.push(sheet); + } + } + } + } + } + Ok(sheets) +} + +fn n(v: f64) -> String { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +fn decoded_contents(doc: &Document, page: &Dict) -> Vec { + let list: Vec = match page.get(b"Contents") { + None => Vec::new(), + Some(c) => match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }, + }; + let mut out = Vec::new(); + for o in list { + if let Object::Stream(s) = &*doc.resolve(&o) + && let Ok(d) = s.decoded() + { + out.extend_from_slice(&d); + out.push(b'\n'); + } + } + out +} + +/// Does annotation `d` print under `content`? +fn prints(doc: &Document, d: &Dict, content: Content) -> bool { + const HIDDEN: i64 = 2; + const PRINT: i64 = 4; + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + if flags & PRINT == 0 || flags & HIDDEN != 0 { + return false; + } + let subtype = d.name(b"Subtype").unwrap_or(b""); + match subtype { + b"Widget" => true, + b"Link" | b"Popup" => false, + b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly, + _ => content == Content::DocumentAndMarkups, + } +} + +/// The normal appearance stream to draw for an annotation (honouring `/AS`). +fn appearance(doc: &Document, d: &Dict) -> Option { + let ap = doc.resolve(d.get(b"AP")?); + let normal = ap.as_dict()?.get(b"N")?.clone(); + match &*doc.resolve(&normal) { + Object::Stream(_) => normal.as_ref(), + Object::Dict(states) => { + let state = d.name(b"AS")?; + states.get(state)?.as_ref() + } + _ => None, + } +} + +/// Algorithm 8.1: the matrix placing an appearance form (BBox × Matrix) onto the annotation rect. +fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option { + let nums = + |k: &[u8]| -> Option> { Some(doc.resolve(form.get(k)?).as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()) }; + let bbox = nums(b"BBox").filter(|b| b.len() == 4)?; + let m = nums(b"Matrix").filter(|m| m.len() == 6).map(|m| Matrix([m[0], m[1], m[2], m[3], m[4], m[5]])).unwrap_or_default(); + let b = m.bbox([bbox[0], bbox[1], bbox[2], bbox[3]]); + let (bw, bh) = (b[2] - b[0], b[3] - b[1]); + if bw <= 0.0 || bh <= 0.0 { + return None; + } + let a = Matrix([ + (rect[2] - rect[0]) / bw, + 0.0, + 0.0, + (rect[3] - rect[1]) / bh, + rect[0] - b[0] * (rect[2] - rect[0]) / bw, + rect[1] - b[1] * (rect[3] - rect[1]) / bh, + ]); + Some(m.then(&a)) +} + +/// A Form XObject holding page `index` as it prints (user space; BBox = crop box). +fn page_form(doc: &mut Document, index: usize, content: Content) -> Result { + let page = printcraft_model::pages(doc).swap_remove(index); + let crop = page.crop(doc); + let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut body = if content == Content::FormFieldsOnly { Vec::new() } else { decoded_contents(doc, &page.dict) }; + // Wrap the page content so a state it leaves behind can't affect the annotations. + if !body.is_empty() { + let mut wrapped = b"q\n".to_vec(); + wrapped.extend_from_slice(&body); + wrapped.extend_from_slice(b"\nQ\n"); + body = wrapped; + } + let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + let mut k = 0; + for a in annots { + let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; + if !prints(doc, &d, content) { + continue; + } + let rect: Vec = + d.get(b"Rect").and_then(|r| doc.resolve(r).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); + if rect.len() != 4 { + continue; + } + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let Some(ap) = appearance(doc, &d) else { continue }; + let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); + let Some(m) = appearance_matrix(doc, &form, rect) else { continue }; + let name = format!("PCPrA{k}"); + k += 1; + let [a0, b0, c0, d0, e0, f0] = m.0; + body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); + xobjects.set(name.into_bytes(), Object::Ref(ap)); + } + if !xobjects.is_empty() { + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + } + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect())); + d.set(b"Resources".to_vec(), Object::Dict(res)); + if let Some(g) = page.dict.get(b"Group") { + d.set(b"Group".to_vec(), g.clone()); + } + Ok(doc.add(Object::Stream(Stream::flate(d, &body)))) +} + +/// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a +/// printer or is the user's own copy); callers must check the print permission first. +pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError> { + let pages = printcraft_model::pages(src); + let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect(); + let sheets = layout(&sizes, settings)?; + let mut doc = src.clone(); + // One form per source page used (a page may appear on several sheets: posters). + let mut forms: std::collections::HashMap = std::collections::HashMap::new(); + let views: Vec<[f64; 6]> = pages.iter().map(|p| p.view_matrix(src)).collect(); + let pages_root = doc.add(Object::Null); + let mut kids = Vec::new(); + for sheet in &sheets { + let mut c = String::new(); + let mut xo = Dict::new(); + for (i, pl) in sheet.placed.iter().enumerate() { + let form = match forms.get(&pl.page) { + Some(f) => *f, + None => { + let f = page_form(&mut doc, pl.page, settings.content)?; + forms.insert(pl.page, f); + f + } + }; + let name = format!("P{i}"); + xo.set(name.clone().into_bytes(), Object::Ref(form)); + // Display space → sheet, then clip to the visible part, then user → display. + let [a, b, cc, d, e, f] = pl.matrix.0; + let to_display = Matrix(views[pl.page]).invert().unwrap_or_default(); + let [ua, ub, uc, ud, ue, uf] = to_display.0; + let [x0, y0, x1, y1] = pl.clip; + c.push_str(&format!( + "q {} {} {} {} {} {} cm {} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do Q\n", + n(a), + n(b), + n(cc), + n(d), + n(e), + n(f), + n(x0), + n(y0), + n(x1 - x0), + n(y1 - y0), + n(ua), + n(ub), + n(uc), + n(ud), + n(ue), + n(uf) + )); + } + if !sheet.borders.is_empty() || !sheet.lines.is_empty() { + c.push_str("q 0 G 0.5 w\n"); + for r in &sheet.borders { + c.push_str(&format!("{} {} {} {} re S\n", n(r[0]), n(r[1]), n(r[2] - r[0]), n(r[3] - r[1]))); + } + for l in &sheet.lines { + c.push_str(&format!("{} {} m {} {} l S\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); + } + c.push_str("Q\n"); + } + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), c.as_bytes()))); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages_root)); + p.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, sheet.size.0, sheet.size.1].iter().map(|v| Object::Real(*v)).collect())); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(contents)); + kids.push(Object::Ref(doc.add(Object::Dict(p)))); + } + let mut tree = Dict::new(); + tree.set(b"Type".to_vec(), Object::name("Pages")); + tree.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + tree.set(b"Kids".to_vec(), Object::Array(kids)); + doc.set(pages_root, Object::Dict(tree)); + let mut cat = Dict::new(); + cat.set(b"Type".to_vec(), Object::name("Catalog")); + cat.set(b"Pages".to_vec(), Object::Ref(pages_root)); + let root = doc.add(Object::Dict(cat)); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); + doc.trailer_mut().remove(b"Info"); + if doc.trailer().contains(b"Encrypt") { + doc.remove_encryption(); + } + Ok(write_full(&doc, &SaveOptions::default())?) +} + +/// Which source pages land on each sheet (for previews and the sheet count). +pub fn sheet_pages(sheets: &[Sheet]) -> Vec> { + sheets.iter().map(|s| s.placed.iter().map(|p| p.page).collect()).collect() +} diff --git a/crates/print/src/range.rs b/crates/print/src/range.rs new file mode 100644 index 000000000..063874623 --- /dev/null +++ b/crates/print/src/range.rs @@ -0,0 +1,65 @@ +//! Pages to Print: all, a range ("1-3, 6, 9-", page labels allowed), odd or even pages, reverse. + +use crate::PrintError; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Subset { + #[default] + All, + Odd, + Even, +} + +/// Resolve one range token: a page label, or a number (1-based). +fn page_of(tok: &str, count: usize, labels: &[String]) -> Result { + let t = tok.trim(); + // Page labels win over numbers (Acrobat's "use logical page numbers"). + if let Some(i) = labels.iter().position(|l| l == t) { + return Ok(i); + } + match t.parse::() { + Ok(n) if n >= 1 && n <= count => Ok(n - 1), + Ok(n) => Err(PrintError::Invalid(format!("page {n} is out of range (1–{count})"))), + Err(_) => Err(PrintError::Invalid(format!("{t:?} is not a page number or label"))), + } +} + +/// The pages to print (0-based, in print order). `range` is `None` for all pages; it may list +/// numbers and labels with `-` ranges (open-ended allowed: `5-`, `-3`). The subset counts the +/// selected pages (the first selected is "odd"), as Acrobat does. +pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset: Subset, reverse: bool) -> Result, PrintError> { + let mut pages = Vec::new(); + match range.map(str::trim).filter(|r| !r.is_empty()) { + None => pages.extend(0..count), + Some(r) => { + for part in r.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) { + match part.split_once('-') { + // A label may itself contain a dash ("A-1"): try the whole token first. + Some(_) if labels.iter().any(|l| l == part) => pages.push(page_of(part, count, labels)?), + Some((a, b)) => { + let from = if a.trim().is_empty() { 0 } else { page_of(a, count, labels)? }; + let to = if b.trim().is_empty() { count.saturating_sub(1) } else { page_of(b, count, labels)? }; + if from <= to { + pages.extend(from..=to); + } else { + pages.extend((to..=from).rev()); + } + } + None => pages.push(page_of(part, count, labels)?), + } + } + } + } + let mut out: Vec = match subset { + Subset::All => pages, + Subset::Odd => pages.into_iter().step_by(2).collect(), + Subset::Even => pages.into_iter().skip(1).step_by(2).collect(), + }; + if reverse { + out.reverse(); + } + if out.is_empty() { + return Err(PrintError::NoPages); + } + Ok(out) +} diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs new file mode 100644 index 000000000..8ca21758a --- /dev/null +++ b/crates/print/src/spool.rs @@ -0,0 +1,111 @@ +//! The system print spooler. On macOS and Linux this is CUPS: printers come from `lpstat`, jobs +//! go to `lp` with the job options (copies, collation, duplex, colour). Other platforms report +//! that printing isn't available yet; the print-ready PDF can still be saved. + +use crate::PrintError; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Printer { + pub name: String, + pub default: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Duplex { + #[default] + Off, + LongEdge, + ShortEdge, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Job { + /// `None` = the system default printer. + pub printer: Option, + pub copies: u32, + pub collate: bool, + pub duplex: Duplex, + pub grayscale: bool, + pub title: String, +} + +impl Default for Job { + fn default() -> Self { + Job { printer: None, copies: 1, collate: true, duplex: Duplex::Off, grayscale: false, title: "PrintCraft".into() } + } +} + +/// Parse `lpstat -p -d` output. +pub fn parse_lpstat(out: &str) -> Vec { + let default = out.lines().find_map(|l| l.strip_prefix("system default destination:")).map(|s| s.trim().to_string()); + out.lines() + .filter_map(|l| l.strip_prefix("printer ")) + .filter_map(|l| l.split_whitespace().next()) + .map(|n| Printer { name: n.to_string(), default: default.as_deref() == Some(n) }) + .collect() +} + +/// The `lp` arguments for a job printing `file`. +pub fn lp_args(job: &Job, file: &str) -> Vec { + let mut a = Vec::new(); + if let Some(p) = &job.printer { + a.extend(["-d".to_string(), p.clone()]); + } + a.extend(["-n".to_string(), job.copies.clamp(1, 999).to_string()]); + a.extend(["-t".to_string(), job.title.clone()]); + let mut opt = |o: &str| a.extend(["-o".to_string(), o.to_string()]); + opt(if job.collate { "collate=true" } else { "collate=false" }); + opt(match job.duplex { + Duplex::Off => "sides=one-sided", + Duplex::LongEdge => "sides=two-sided-long-edge", + Duplex::ShortEdge => "sides=two-sided-short-edge", + }); + if job.grayscale { + opt("print-color-mode=monochrome"); + } + // The sheets are already laid out at their final size. + opt("fit-to-page=false"); + a.push("--".into()); + a.push(file.to_string()); + a +} + +/// The printers the system knows (empty when there are none or no spooler). +pub fn printers() -> Vec { + #[cfg(all(unix, not(target_arch = "wasm32")))] + { + match std::process::Command::new("lpstat").args(["-p", "-d"]).output() { + Ok(o) => parse_lpstat(&String::from_utf8_lossy(&o.stdout)), + Err(_) => Vec::new(), + } + } + #[cfg(not(all(unix, not(target_arch = "wasm32"))))] + { + Vec::new() + } +} + +/// Send a print-ready PDF to the spooler. Returns the spooler's message (the job id). +pub fn submit(pdf: &[u8], job: &Job) -> Result { + #[cfg(all(unix, not(target_arch = "wasm32")))] + { + let dir = std::env::temp_dir().join(format!("printcraft-print-{}", std::process::id())); + std::fs::create_dir_all(&dir).map_err(|e| PrintError::Spool(e.to_string()))?; + let file = dir.join(format!("job-{}.pdf", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()))); + std::fs::write(&file, pdf).map_err(|e| PrintError::Spool(e.to_string()))?; + let out = std::process::Command::new("lp").args(lp_args(job, &file.to_string_lossy())).output(); + let _ = std::fs::remove_file(&file); + let out = out.map_err(|e| PrintError::Spool(format!("the print spooler is not available: {e}")))?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); + Err(PrintError::Spool(if err.is_empty() { "the print job was refused".into() } else { err })) + } + } + #[cfg(not(all(unix, not(target_arch = "wasm32"))))] + { + let _ = (pdf, job); + Err(PrintError::Spool("printing to a printer isn't available on this platform yet; save the print-ready PDF instead".into())) + } +} diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs new file mode 100644 index 000000000..f2a09d6da --- /dev/null +++ b/crates/print/src/tests.rs @@ -0,0 +1,193 @@ +use std::sync::Arc; + +use super::*; +use crate::spool::{Duplex, Job, lp_args, parse_lpstat}; + +/// `n` pages of 200×300 (page 3 is landscape 300×200 when n ≥ 3); page i shows "(Page i+1)". +/// Page 1 carries a printable square comment, a non-printing note, and a stamp. +fn fixture(n: usize) -> Document { + let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 6 + 2 * i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes()); + objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec()); + // 4: an appearance stream; 5: unused. + let ap = b"0 0 1 rg 0 0 10 10 re f"; + objs.push( + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length {} >>\nstream\n{}\nendstream", ap.len(), String::from_utf8_lossy(ap)) + .into_bytes(), + ); + objs.push(b"null".to_vec()); + for i in 0..n { + let annots = if i == 0 { + " /Annots [<< /Type /Annot /Subtype /Square /F 4 /Rect [10 10 50 50] /AP << /N 4 0 R >> >> << /Type /Annot /Subtype /Text /F 0 /Rect [60 10 80 30] /AP << /N 4 0 R >> >> << /Type /Annot /Subtype /Stamp /F 4 /Rect [100 10 140 50] /AP << /N 4 0 R >> >>]" + } else { + "" + }; + let mb = if i == 2 { " /MediaBox [0 0 300 200]" } else { "" }; + objs.push( + format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >>{mb}{annots} >>", 7 + 2 * i).into_bytes(), + ); + let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1); + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes()); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn settings(pages: Vec, layout: Layout) -> Settings { + Settings { pages, paper: (612.0, 792.0), layout, ..Settings::default() } +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 0.01 +} + +#[test] +fn page_selection() { + let labels: Vec = ["i", "ii", "1", "2", "A-1"].iter().map(|s| s.to_string()).collect(); + assert_eq!(select_pages(5, None, &labels, Subset::All, false).unwrap(), [0, 1, 2, 3, 4]); + assert_eq!(select_pages(5, Some("1-2, 5"), &[], Subset::All, false).unwrap(), [0, 1, 4]); + assert_eq!(select_pages(5, Some("4-"), &[], Subset::All, false).unwrap(), [3, 4]); + assert_eq!(select_pages(5, Some("-2"), &[], Subset::All, false).unwrap(), [0, 1]); + assert_eq!(select_pages(5, Some("ii-2, A-1"), &labels, Subset::All, false).unwrap(), [1, 2, 3, 4], "labels, even with a dash"); + assert_eq!(select_pages(5, None, &[], Subset::Even, true).unwrap(), [3, 1]); + assert_eq!(select_pages(5, None, &[], Subset::Odd, false).unwrap(), [0, 2, 4]); + assert!(matches!(select_pages(5, Some("7"), &[], Subset::All, false), Err(PrintError::Invalid(_)))); + assert!(matches!(select_pages(5, Some("x"), &[], Subset::All, false), Err(PrintError::Invalid(_)))); + assert_eq!(select_pages(1, None, &[], Subset::Even, false), Err(PrintError::NoPages)); +} + +#[test] +fn size_modes() { + let sizes = [(200.0, 300.0), (1000.0, 1500.0)]; + let fit = layout(&sizes, &settings(vec![0, 1], Layout::Size(SizeMode::Fit))).unwrap(); + let s0 = fit[0].placed[0].matrix.0[0]; + assert!(close(s0, 2.52), "min(576 / 200, 756 / 300): {s0}"); + let actual = layout(&sizes, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap(); + assert_eq!(actual[0].placed[0].matrix.0, [1.0, 0.0, 0.0, 1.0, 206.0, 246.0], "centred at 100%"); + let shrink = layout(&sizes, &settings(vec![0, 1], Layout::Size(SizeMode::Shrink))).unwrap(); + assert_eq!(shrink[0].placed[0].matrix.0[0], 1.0, "small pages stay at 100%"); + assert!(shrink[1].placed[0].matrix.0[0] < 1.0, "big pages shrink"); + let custom = layout(&sizes, &settings(vec![0], Layout::Size(SizeMode::Custom(50.0)))).unwrap(); + assert_eq!(custom[0].placed[0].matrix.0[0], 0.5); + // Auto orientation turns the sheet for landscape pages. + let land = layout(&[(300.0, 200.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); + assert_eq!(land[0].size, (792.0, 612.0)); +} + +#[test] +fn multiple_pages_per_sheet() { + let sizes = vec![(200.0, 300.0); 5]; + let sheets = layout(&sizes, &settings((0..5).collect(), Layout::multiple(4))).unwrap(); + assert_eq!(sheet_pages(&sheets), [vec![0, 1, 2, 3], vec![4]]); + // Horizontal order: 0 top-left, 1 top-right, 2 bottom-left. + let o = |k: usize| (sheets[0].placed[k].matrix.0[4], sheets[0].placed[k].matrix.0[5]); + assert!(o(1).0 > o(0).0 && close(o(1).1, o(0).1) && o(2).1 < o(0).1 && close(o(2).0, o(0).0)); + // Two per sheet prints side by side on landscape paper. + let two = layout(&sizes, &settings(vec![0, 1], Layout::multiple(2))).unwrap(); + assert_eq!(two[0].size, (792.0, 612.0)); + assert!(two[0].placed[1].matrix.0[4] > two[0].placed[0].matrix.0[4]); + // Vertical order and borders. + let v = + layout(&sizes, &settings(vec![0, 1, 2], Layout::Multiple { cols: 2, rows: 2, order: PageOrder::Vertical, border: true, auto_rotate: false })) + .unwrap(); + assert!(close(v[0].placed[1].matrix.0[4], v[0].placed[0].matrix.0[4]) && v[0].placed[1].matrix.0[5] < v[0].placed[0].matrix.0[5]); + assert_eq!(v[0].borders.len(), 3); + // Auto-rotate turns a landscape page in a portrait cell. + let r = layout(&[(300.0, 200.0), (200.0, 300.0)], &settings(vec![0, 1], Layout::multiple(4))).unwrap(); + // The first (landscape) page sets a landscape sheet; the portrait page is turned to fit. + assert_eq!(r[0].size, (792.0, 612.0)); + assert!(r[0].placed[0].matrix.0[0] > 0.0); + assert_eq!(r[0].placed[1].matrix.0[0], 0.0, "rotated"); +} + +#[test] +fn booklets_pair_pages_for_folding() { + let sizes = vec![(200.0, 300.0); 6]; + let sheets = layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left })).unwrap(); + // 6 pages pad to 8: sheet 1 front [8,1] → [blank, 0], back [1, 6]; sheet 2 front [5, 2], back [3, 4]. + assert_eq!(sheet_pages(&sheets), [vec![0], vec![1], vec![5, 2], vec![3, 4]]); + assert!(sheets[0].placed[0].matrix.0[4] >= 395.9, "page 1 on the right half"); + assert_eq!(sheets[0].size, (792.0, 612.0)); + let front = layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::FrontOnly, binding: Binding::Right })).unwrap(); + assert_eq!(sheet_pages(&front), [vec![0], vec![2, 5]], "right binding mirrors the spread"); +} + +#[test] +fn posters_tile_with_overlap() { + // A 200×300 page at 400% = 800×1200 on Letter with 18 pt margins (576×756 tiles), overlap 36. + let sheets = layout(&[(200.0, 300.0)], &settings(vec![0], Layout::Poster { scale: 400.0, overlap: 36.0, cut_marks: true })).unwrap(); + assert_eq!(sheets.len(), 4, "2 × 2 tiles"); + assert_eq!(sheets[0].lines.len(), 8, "cut marks"); + let c0 = sheets[0].placed[0].clip; + let c1 = sheets[1].placed[0].clip; + assert!(close(c0[2] - c1[0], 36.0 / 4.0), "neighbouring tiles share the overlap"); + assert!(layout(&[(200.0, 300.0)], &settings(vec![0], Layout::Poster { scale: 400.0, overlap: 400.0, cut_marks: false })).is_err()); +} + +#[test] +fn imposed_pdf_has_the_sheets_and_honours_comments_and_forms() { + let doc = fixture(3); + let out = impose(&doc, &settings(vec![0, 1, 2], Layout::multiple(2))).unwrap(); + let printed = Document::open(Arc::new(out.clone())).unwrap(); + let pages = printcraft_model::pages(&printed); + assert_eq!(pages.len(), 2); + assert_eq!(pages[0].crop(&printed), [0.0, 0.0, 792.0, 612.0]); + // The source pages are form XObjects holding their content. + let streams = |d: &Document| -> Vec { + d.object_numbers() + .into_iter() + .filter_map(|n| match &*d.get(ObjRef::new(n, d.generation(n))) { + Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + _ => None, + }) + .collect() + }; + let all = streams(&printed).join("\n"); + assert!(all.contains("(Page 1)") && all.contains("(Page 3)")); + // Markups: the printable square and the stamp, not the note without the Print flag. + let page1 = streams(&printed).into_iter().find(|s| s.contains("(Page 1)")).unwrap(); + assert_eq!(page1.matches(" Do Q").count(), 2, "{page1}"); + let doc_only = impose(&doc, &Settings { content: Content::Document, ..settings(vec![0], Layout::Size(SizeMode::Fit)) }).unwrap(); + let p = streams(&Document::open(Arc::new(doc_only)).unwrap()).into_iter().find(|s| s.contains("(Page 1)")).unwrap(); + assert_eq!(p.matches(" Do Q").count(), 0); + let stamps = impose(&doc, &Settings { content: Content::DocumentAndStamps, ..settings(vec![0], Layout::Size(SizeMode::Fit)) }).unwrap(); + let p = streams(&Document::open(Arc::new(stamps)).unwrap()).into_iter().find(|s| s.contains("(Page 1)")).unwrap(); + assert_eq!(p.matches(" Do Q").count(), 1); + let fields = impose(&doc, &Settings { content: Content::FormFieldsOnly, ..settings(vec![1], Layout::Size(SizeMode::Fit)) }).unwrap(); + assert!(!streams(&Document::open(Arc::new(fields)).unwrap()).join("").contains("(Page 2)")); + // The output is a fresh file: the unused source pages are gone. + let one = impose(&doc, &settings(vec![1], Layout::Size(SizeMode::Fit))).unwrap(); + let s = streams(&Document::open(Arc::new(one)).unwrap()).join("\n"); + assert!(s.contains("(Page 2)") && !s.contains("(Page 1)")); +} + +#[test] +fn spooler_arguments_and_printer_list() { + let out = "printer Office_Laser is idle. enabled since Thu Oct 1 09:00:00 2026\nprinter Label_Writer disabled since …\nsystem default destination: Office_Laser\n"; + assert_eq!( + parse_lpstat(out), + [spool::Printer { name: "Office_Laser".into(), default: true }, spool::Printer { name: "Label_Writer".into(), default: false }] + ); + assert!(parse_lpstat("lpstat: No destinations added.\nno system default destination\n").is_empty()); + let job = + Job { printer: Some("Office_Laser".into()), copies: 3, collate: false, duplex: Duplex::LongEdge, grayscale: true, title: "memo.pdf".into() }; + assert_eq!( + lp_args(&job, "/tmp/x.pdf").join(" "), + "-d Office_Laser -n 3 -t memo.pdf -o collate=false -o sides=two-sided-long-edge -o print-color-mode=monochrome -o fit-to-page=false -- /tmp/x.pdf" + ); + assert_eq!(lp_args(&Job::default(), "f.pdf")[0], "-n", "no -d: the default printer"); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index fd3804d0a..fc1baaa8a 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -34,5 +34,6 @@ web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlE [dev-dependencies] printcraft-cos = { workspace = true } +printcraft-model = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ff69a6539..1a6221284 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -314,6 +314,11 @@ impl DocView { (!quads.is_empty()).then_some((s.page, quads)) } + /// A page's thumbnail texture, when rendered (the print preview uses them). + pub(crate) fn thumb_id(&self, page: usize) -> Option { + self.thumbs.get(&page).map(|t| t.id()) + } + pub(crate) fn page_text(&self, page: usize) -> Option> { self.texts.get(&page).cloned() } @@ -725,7 +730,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { ui.centered_and_justified(|ui| ui.label("This document has no pages.")); return; } - let want_thumbs = app.right == Some(RightPanel::Pages) || app.views[index].organize; + let want_thumbs = app.right == Some(RightPanel::Pages) || app.views[index].organize || app.dialog == Some(crate::Dialog::Print); // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. let preparing = (app.left_open && app.left == crate::LeftPanel::Tool("form")) || matches!(app.quick_tool, QuickTool::Field(_)); let view = &mut app.views[index]; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 574bc7b7b..acb0db68e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -133,7 +133,7 @@ pub fn mode_bar(app: &mut PrintCraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.x = 4.0; let has_doc = app.active.is_some(); ui.add_enabled_ui(has_doc, |ui| { - if icons::button(ui, "printer", 32.0, false, "Print (M10)").clicked() { + if icons::button(ui, "printer", 32.0, false, "Print (⌘P)").clicked() { app.run_command("print.dialog"); } if icons::button(ui, "save", 32.0, false, "Save (M4)").clicked() { diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index ff9c470f4..1d0ece272 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -245,6 +245,7 @@ impl PrintCraftApp { } } "protect.remove_hidden" => self.open_remove_hidden(), + "print.dialog" => self.open_print(), "redact.sanitize" => self.dialog = Some(Dialog::Sanitize), "redact.clear" => { self.apply_edit(Edit::ClearRedactions); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 30397719f..80563a881 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -37,12 +37,17 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { let mut props_now = false; let mut field_props_now = false; let mut redact_now: Option = None; + let mut print_go = false; let mut replace_now = false; let t = Tokens::get(ctx); let mut close = false; let mut next = dialog; let modal = egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| { - ui.set_width(if matches!(dialog, Dialog::Properties(_)) { 640.0 } else { 520.0 }); + ui.set_width(match dialog { + Dialog::Properties(_) => 640.0, + Dialog::Print => 820.0, + _ => 520.0, + }); // Dialog controls are outlined (radio buttons, check boxes, combo boxes and number fields // would otherwise blend into the dialog, whose fill matches the theme's field colour). let w = &mut ui.visuals_mut().widgets; @@ -311,6 +316,21 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { close = ok || cancel; return; } + Dialog::Print => { + let Some((i, id)) = app.active_ids() else { + close = true; + return; + }; + let Some(doc) = app.session.get(id) else { return }; + let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); + let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect(); + let thumbs: std::collections::HashMap = + (0..sizes.len()).filter_map(|p| app.views[i].thumb_id(p).map(|t| (p, t))).collect(); + let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, &|p| thumbs.get(&p).copied()); + print_go = go; + close = go || cancel; + return; + } Dialog::RemoveHidden => { let (ok, cancel) = crate::redact_ui::hidden_body(ui, &mut app.hidden_draft, &t); if ok { @@ -585,6 +605,9 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { src_pages: (d.src_from - 1..d.src_from - 1 + n).collect(), }); } + if print_go { + app.print_now(); + } match redact_now { Some(Dialog::RedactPages) => app.redact_pages(), Some(Dialog::RedactSearch) => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ad59b6025..9fb0b26fd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,8 @@ mod pageboxes; mod palette; mod panels; pub mod prepare; +mod print_ui; +pub use print_ui::{Handling as PrintHandling, PrintDraft, Which as PrintWhich}; mod redact_ui; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft}; mod protect; @@ -121,6 +123,8 @@ pub enum Dialog { RedactSearch, RedactProps, RedactApply, + /// File ▸ Print. + Print, /// Remove Hidden Information and Sanitize Document. RemoveHidden, Sanitize, @@ -232,6 +236,7 @@ pub struct PrintCraftApp { pub redact_pages_draft: RedactPagesDraft, pub redact_search: RedactSearchDraft, pub hidden_draft: HiddenDraft, + pub print_draft: PrintDraft, /// The Replace Pages dialog's state. pub replace_draft: Option, /// The last web link the app asked the system to open (tests and automation). @@ -309,6 +314,7 @@ impl PrintCraftApp { redact_pages_draft: RedactPagesDraft::default(), redact_search: RedactSearchDraft::default(), hidden_draft: HiddenDraft::default(), + print_draft: PrintDraft::default(), replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, } diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs new file mode 100644 index 000000000..804cc2de4 --- /dev/null +++ b/crates/ui-egui/src/print_ui.rs @@ -0,0 +1,488 @@ +//! The Print dialog (Acrobat's File ▸ Print, execution plan M10.5): printer, copies, grayscale; +//! pages to print (all, current, range with labels; odd/even, reverse); page sizing & handling +//! (Size, Poster, Multiple, Booklet); orientation; comments & forms; and a live preview of the +//! sheets. Printing sends the print-ready PDF to the system spooler; "Save as PDF" writes it. + +use egui::{Color32, Pos2, Rect, Stroke, pos2, vec2}; +use printcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool}; + +use crate::theme::{self, Tokens}; +use crate::{PrintCraftApp, widgets}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Which { + All, + Current, + Range, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Handling { + Size, + Poster, + Multiple, + Booklet, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PrintDraft { + pub printers: Vec, + /// `None` = Save as PDF. + pub printer: Option, + pub copies: u32, + pub collate: bool, + pub grayscale: bool, + pub duplex: spool::Duplex, + pub which: Which, + pub range: String, + pub subset: Subset, + pub reverse: bool, + pub handling: Handling, + pub size: SizeMode, + pub custom_scale: f64, + pub per_sheet: usize, + pub order: PageOrder, + pub border: bool, + pub auto_rotate: bool, + pub booklet_subset: BookletSubset, + pub binding: Binding, + pub poster_scale: f64, + pub overlap: f64, + pub cut_marks: bool, + pub orientation: Orientation, + pub content: Content, + pub paper: usize, + /// The sheet shown in the preview (0-based). + pub sheet: usize, + pub current_page: usize, +} + +impl Default for PrintDraft { + fn default() -> Self { + PrintDraft { + printers: Vec::new(), + printer: None, + copies: 1, + collate: true, + grayscale: false, + duplex: spool::Duplex::Off, + which: Which::All, + range: String::new(), + subset: Subset::All, + reverse: false, + handling: Handling::Size, + size: SizeMode::Fit, + custom_scale: 100.0, + per_sheet: 2, + order: PageOrder::Horizontal, + border: false, + auto_rotate: true, + booklet_subset: BookletSubset::BothSides, + binding: Binding::Left, + poster_scale: 200.0, + overlap: 18.0, + cut_marks: true, + orientation: Orientation::Auto, + content: Content::DocumentAndMarkups, + paper: 0, + sheet: 0, + current_page: 0, + } + } +} + +impl PrintDraft { + /// The engine settings for this draft (page count and labels from the document). + pub fn settings(&self, count: usize, labels: &[String]) -> Result { + let range = match self.which { + Which::All => None, + Which::Current => Some((self.current_page + 1).to_string()), + Which::Range => Some(self.range.clone()), + }; + let pages = print::select_pages(count, range.as_deref(), labels, self.subset, self.reverse).map_err(|e| e.to_string())?; + let layout = match self.handling { + Handling::Size => Layout::Size(match self.size { + SizeMode::Custom(_) => SizeMode::Custom(self.custom_scale), + m => m, + }), + Handling::Multiple => match Layout::multiple(self.per_sheet) { + Layout::Multiple { cols, rows, .. } => { + Layout::Multiple { cols, rows, order: self.order, border: self.border, auto_rotate: self.auto_rotate } + } + other => other, + }, + Handling::Booklet => Layout::Booklet { subset: self.booklet_subset, binding: self.binding }, + Handling::Poster => Layout::Poster { scale: self.poster_scale, overlap: self.overlap, cut_marks: self.cut_marks }, + }; + Ok(print::Settings { pages, paper: PAPERS[self.paper.min(PAPERS.len() - 1)].1, orientation: self.orientation, layout, content: self.content }) + } + + pub fn job(&self, title: &str) -> spool::Job { + spool::Job { + printer: self.printer.clone(), + copies: self.copies.max(1), + collate: self.collate, + duplex: self.duplex, + grayscale: self.grayscale, + title: title.to_string(), + } + } +} + +impl PrintCraftApp { + pub fn open_print(&mut self) { + let Some((i, _)) = self.active_ids() else { return }; + let printers = spool::printers(); + let default = printers.iter().find(|p| p.default).or(printers.first()).map(|p| p.name.clone()); + let current = self.views[i].current; + let keep = std::mem::take(&mut self.print_draft); + self.print_draft = PrintDraft { printers, printer: default, current_page: current, sheet: 0, ..keep }; + self.dialog = Some(crate::Dialog::Print); + } + + /// Print (or save) with the dialog's settings. Returns `true` on success. + pub fn print_now(&mut self) -> bool { + let Some((_, id)) = self.active_ids() else { return false }; + let Some(doc) = self.session.get(id) else { return false }; + let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect(); + let settings = match self.print_draft.settings(doc.info.pages.len(), &labels) { + Ok(s) => s, + Err(e) => { + self.notify(e); + return false; + } + }; + let name = doc.name.clone(); + let bytes = match self.session.print_pdf(id, &settings) { + Ok(b) => b, + Err(e) => { + self.notify(e.to_string()); + return false; + } + }; + match self.print_draft.printer.clone() { + Some(printer) => match spool::submit(&bytes, &self.print_draft.job(&name)) { + Ok(msg) => { + self.notify(if msg.is_empty() { format!("Sent to {printer}") } else { format!("Sent to {printer}: {msg}") }); + true + } + Err(e) => { + self.notify(e.to_string()); + false + } + }, + None => { + let stem = name.trim_end_matches(".pdf").trim_end_matches(".PDF"); + let path = match self.save_override.clone() { + Some(p) => Some(std::path::PathBuf::from(p)), + #[cfg(not(target_arch = "wasm32"))] + None => rfd::FileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(format!("{stem} (print).pdf")).save_file(), + #[cfg(target_arch = "wasm32")] + None => None, + }; + let Some(path) = path else { return false }; + match std::fs::write(&path, &bytes) { + Ok(()) => { + self.notify(format!("Saved the print-ready PDF to {}", path.display())); + true + } + Err(e) => { + self.notify(format!("Could not save: {e}")); + false + } + } + } + } + } +} + +fn combo(ui: &mut egui::Ui, id: &str, value: &mut T, choices: &[(T, &str)], width: f32) { + let shown = choices.iter().find(|c| c.0 == *value).map_or("", |c| c.1); + egui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| { + for (v, label) in choices { + ui.selectable_value(value, *v, *label); + } + }); +} + +/// Draw the dialog. `thumb` gives a page's thumbnail texture when there is one. Returns +/// (print, cancel). +pub(crate) fn body( + ui: &mut egui::Ui, + d: &mut PrintDraft, + t: &Tokens, + sizes: &[(f64, f64)], + labels: &[String], + thumb: &dyn Fn(usize) -> Option, +) -> (bool, bool) { + ui.set_width(820.0); + ui.label(egui::RichText::new("Print").font(theme::semibold(18.0))); + ui.add_space(8.0); + let settings = d.settings(sizes.len(), labels); + let sheets = settings.as_ref().ok().and_then(|s| print::layout(sizes, s).ok()).unwrap_or_default(); + d.sheet = d.sheet.min(sheets.len().saturating_sub(1)); + ui.horizontal_top(|ui| { + // Settings. + ui.vertical(|ui| { + ui.set_width(470.0); + egui::Grid::new("print-top").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + ui.label("Printer:"); + let shown = d.printer.clone().unwrap_or_else(|| "Save as PDF".into()); + egui::ComboBox::from_id_salt("printer").selected_text(shown).width(260.0).show_ui(ui, |ui| { + for p in &d.printers { + let label = if p.default { format!("{} (default)", p.name) } else { p.name.clone() }; + ui.selectable_value(&mut d.printer, Some(p.name.clone()), label); + } + ui.selectable_value(&mut d.printer, None, "Save as PDF"); + }); + ui.end_row(); + ui.label("Copies:"); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut d.copies).range(1..=999)); + ui.checkbox(&mut d.collate, "Collate"); + ui.checkbox(&mut d.grayscale, "Print in grayscale"); + }); + ui.end_row(); + ui.label("Two-sided:"); + combo( + ui, + "duplex", + &mut d.duplex, + &[(spool::Duplex::Off, "Off"), (spool::Duplex::LongEdge, "Flip on long edge"), (spool::Duplex::ShortEdge, "Flip on short edge")], + 160.0, + ); + ui.end_row(); + ui.label("Paper:"); + egui::ComboBox::from_id_salt("paper").selected_text(PAPERS[d.paper].0).width(160.0).show_ui(ui, |ui| { + for (i, (name, _)) in PAPERS.iter().enumerate() { + ui.selectable_value(&mut d.paper, i, *name); + } + }); + ui.end_row(); + }); + ui.add_space(6.0); + widgets::section_title(ui, "Pages to Print"); + ui.horizontal(|ui| { + ui.radio_value(&mut d.which, Which::All, "All"); + ui.radio_value(&mut d.which, Which::Current, "Current page"); + ui.radio_value(&mut d.which, Which::Range, "Pages"); + let r = ui.add_enabled( + d.which == Which::Range, + egui::TextEdit::singleline(&mut d.range).hint_text(format!("1-{}", sizes.len())).desired_width(110.0), + ); + if r.gained_focus() { + d.which = Which::Range; + } + }); + ui.horizontal(|ui| { + ui.label("More options:"); + combo( + ui, + "subset", + &mut d.subset, + &[(Subset::All, "All pages in range"), (Subset::Odd, "Odd pages only"), (Subset::Even, "Even pages only")], + 150.0, + ); + ui.checkbox(&mut d.reverse, "Reverse pages"); + }); + ui.add_space(6.0); + widgets::section_title(ui, "Page Sizing & Handling"); + ui.horizontal(|ui| { + for (h, label) in + [(Handling::Size, "Size"), (Handling::Poster, "Poster"), (Handling::Multiple, "Multiple"), (Handling::Booklet, "Booklet")] + { + if widgets::mode_tab(ui, label, d.handling == h).clicked() { + d.handling = h; + d.sheet = 0; + } + } + }); + ui.add_space(4.0); + match d.handling { + Handling::Size => { + ui.horizontal(|ui| { + ui.radio_value(&mut d.size, SizeMode::Fit, "Fit"); + ui.radio_value(&mut d.size, SizeMode::Actual, "Actual size"); + ui.radio_value(&mut d.size, SizeMode::Shrink, "Shrink oversized pages"); + }); + ui.horizontal(|ui| { + let custom = matches!(d.size, SizeMode::Custom(_)); + if ui.radio(custom, "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(" %")); + }); + } + Handling::Poster => { + ui.horizontal(|ui| { + ui.label("Tile scale:"); + ui.add(egui::DragValue::new(&mut d.poster_scale).range(10.0..=1000.0).suffix(" %")); + ui.label("Overlap:"); + ui.add(egui::DragValue::new(&mut d.overlap).range(0.0..=144.0).suffix(" pt")); + ui.checkbox(&mut d.cut_marks, "Cut marks"); + }); + } + Handling::Multiple => { + ui.horizontal(|ui| { + ui.label("Pages per sheet:"); + combo(ui, "per-sheet", &mut d.per_sheet, &[(2, "2"), (4, "4"), (6, "6"), (9, "9"), (16, "16")], 60.0); + ui.label("Page order:"); + combo( + ui, + "order", + &mut d.order, + &[ + (PageOrder::Horizontal, "Horizontal"), + (PageOrder::HorizontalReversed, "Horizontal reversed"), + (PageOrder::Vertical, "Vertical"), + (PageOrder::VerticalReversed, "Vertical reversed"), + ], + 150.0, + ); + }); + ui.horizontal(|ui| { + ui.checkbox(&mut d.border, "Print page border"); + ui.checkbox(&mut d.auto_rotate, "Auto-rotate pages"); + }); + } + Handling::Booklet => { + ui.horizontal(|ui| { + ui.label("Booklet subset:"); + combo( + ui, + "booklet", + &mut d.booklet_subset, + &[ + (BookletSubset::BothSides, "Both sides"), + (BookletSubset::FrontOnly, "Front side only"), + (BookletSubset::BackOnly, "Back side only"), + ], + 130.0, + ); + ui.label("Binding:"); + combo(ui, "binding", &mut d.binding, &[(Binding::Left, "Left"), (Binding::Right, "Right")], 80.0); + }); + } + } + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label("Orientation:"); + ui.radio_value(&mut d.orientation, Orientation::Auto, "Auto portrait/landscape"); + ui.radio_value(&mut d.orientation, Orientation::Portrait, "Portrait"); + ui.radio_value(&mut d.orientation, Orientation::Landscape, "Landscape"); + }); + ui.add_space(6.0); + widgets::section_title(ui, "Comments & Forms"); + combo( + ui, + "content", + &mut d.content, + &[ + (Content::Document, "Document"), + (Content::DocumentAndMarkups, "Document and markups"), + (Content::DocumentAndStamps, "Document and stamps"), + (Content::FormFieldsOnly, "Form fields only"), + ], + 220.0, + ); + }); + ui.add_space(12.0); + // Preview. + ui.vertical(|ui| { + ui.set_width(320.0); + let (area, _) = ui.allocate_exact_size(vec2(320.0, 380.0), egui::Sense::hover()); + ui.painter().rect_filled(area, 6.0, t.hover); + match (&settings, sheets.get(d.sheet)) { + (Err(e), _) => { + ui.put(area.shrink(16.0), egui::Label::new(egui::RichText::new(e).color(t.text_muted)).wrap()); + } + (Ok(_), Some(sheet)) => { + let k = ((area.width() - 24.0) / sheet.size.0 as f32).min((area.height() - 24.0) / sheet.size.1 as f32); + let paper = Rect::from_center_size(area.center(), vec2(sheet.size.0 as f32 * k, sheet.size.1 as f32 * k)); + ui.painter().rect_filled(paper, 0.0, Color32::WHITE); + ui.painter().rect_stroke(paper, 0.0, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); + let to_screen = |x: f64, y: f64| -> Pos2 { pos2(paper.left() + x as f32 * k, paper.bottom() - y as f32 * k) }; + let painter = ui.painter().with_clip_rect(paper); + for pl in &sheet.placed { + let (dw, dh) = sizes[pl.page]; + let [x0, y0, x1, y1] = pl.clip; + let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| { + let (sx, sy) = pl.matrix.apply(x, y); + to_screen(sx, sy) + }); + // UVs of the visible part (texture y runs down from the page top). + let uv = |x: f64, y: f64| pos2((x / dw) as f32, (1.0 - y / dh) as f32); + let uvs = [uv(x0, y0), uv(x1, y0), uv(x1, y1), uv(x0, y1)]; + match thumb(pl.page) { + Some(tex) => { + let mut mesh = egui::Mesh::with_texture(tex); + for (p, u) in corners.iter().zip(uvs) { + mesh.vertices.push(egui::epaint::Vertex { + pos: *p, + uv: u, + color: if d.grayscale { Color32::from_gray(235) } else { Color32::WHITE }, + }); + } + mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]); + painter.add(egui::Shape::mesh(mesh)); + } + None => { + painter.add(egui::Shape::convex_polygon( + corners.to_vec(), + Color32::from_gray(245), + Stroke::new(0.5, Color32::from_gray(180)), + )); + let c = corners.iter().fold(vec2(0.0, 0.0), |a, p| a + p.to_vec2()) / 4.0; + painter.text( + c.to_pos2(), + egui::Align2::CENTER_CENTER, + (pl.page + 1).to_string(), + theme::regular(11.0), + Color32::from_gray(120), + ); + } + } + } + for b in &sheet.borders { + painter.rect_stroke( + Rect::from_two_pos(to_screen(b[0], b[1]), to_screen(b[2], b[3])), + 0.0, + Stroke::new(0.6, Color32::BLACK), + egui::StrokeKind::Middle, + ); + } + for l in &sheet.lines { + painter.line_segment([to_screen(l[0], l[1]), to_screen(l[2], l[3])], Stroke::new(0.6, Color32::BLACK)); + } + } + _ => {} + } + ui.horizontal(|ui| { + let n = sheets.len(); + if ui.add_enabled(d.sheet > 0, egui::Button::new("‹")).on_hover_text("Previous sheet").clicked() { + d.sheet -= 1; + } + ui.label(if n == 0 { "No sheets".to_string() } else { format!("Sheet {} of {n}", d.sheet + 1) }); + if ui.add_enabled(d.sheet + 1 < n, egui::Button::new("›")).on_hover_text("Next sheet").clicked() { + d.sheet += 1; + } + }); + if let Some(s) = sheets.first() { + let (w, h) = (s.size.0 / 72.0, s.size.1 / 72.0); + ui.label(egui::RichText::new(format!("{w:.2} × {h:.2} in")).small().color(t.text_muted)); + } + }); + }); + ui.add_space(12.0); + let (mut go, mut cancel) = (false, false); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let label = if d.printer.is_some() { "Print" } else { "Save as PDF" }; + if ui.add_enabled_ui(settings.is_ok() && !sheets.is_empty(), |ui| widgets::pill_button(ui, label, true)).inner.clicked() { + go = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + cancel = true; + } + }); + (go, cancel) +} diff --git a/crates/ui-egui/tests/print.rs b/crates/ui-egui/tests/print.rs new file mode 100644 index 000000000..b578e282a --- /dev/null +++ b/crates/ui-egui/tests/print.rs @@ -0,0 +1,52 @@ +//! The Print dialog in the real shell (egui_kittest): settings, preview sheets, Save as PDF. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use printcraft_ui_egui::{Dialog, PrintCraftApp}; + +fn harness() -> Harness<'static, PrintCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app + }); + h.run_steps(4); + h +} + +#[test] +fn print_dialog_lays_out_sheets_and_saves_a_pdf() { + let mut h = harness(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::Print)); + h.get_by_label("Pages to Print".to_uppercase().as_str()); + h.get_by_label("Sheet 1 of 1"); + // Two copies of the one page, as a 2-up poster-free layout: Multiple. + h.get_by_label("Multiple").click(); + h.run_steps(2); + h.get_by_label("Sheet 1 of 1"); + // No printer on a test machine: Save as PDF. + let out = std::env::temp_dir().join(format!("printcraft-print-test-{}.pdf", std::process::id())); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.state_mut().print_draft.printer = None; + h.run_steps(1); + h.get_by_label("Save as PDF").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + let bytes = std::fs::read(&out).expect("saved"); + let doc = printcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + assert_eq!(printcraft_model::pages(&doc).len(), 1); + let _ = std::fs::remove_file(out); +} + +#[test] +fn invalid_ranges_are_explained_in_the_preview() { + let mut h = harness(); + h.state_mut().execute("print.dialog"); + h.run_steps(2); + h.state_mut().print_draft.which = printcraft_ui_egui::PrintWhich::Range; + h.state_mut().print_draft.range = "7".into(); + h.run_steps(2); + h.get_by_label_contains("out of range"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ec643ab5e..4387f7db8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7767,11 +7767,11 @@ title = "Print dialog" area = "L" tier = "P0" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["print.dialog"] +tools = ["doc_print", "printers"] +evidence = ["crates/ui-egui/tests/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render"] +notes = "⌘P; settings and live sheet preview" [[feature]] id = "print.printer-selection" @@ -7779,11 +7779,11 @@ title = "Choose printer and properties" area = "L" tier = "P0" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["print.dialog"] +tools = ["printers", "doc_print"] +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"] +notes = "CUPS printers via lpstat; Save as PDF; Windows spooler pending" [[feature]] id = "print.copies-collate" @@ -7791,11 +7791,11 @@ title = "Copies and collation" area = "L" tier = "P0" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["print.dialog"] +tools = ["doc_print"] +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"] +notes = "lp -n, collate option" [[feature]] id = "print.page-range" @@ -7803,11 +7803,11 @@ title = "Print all, current page or a range (labels allowed)" area = "L" tier = "P0" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["print.dialog"] +tools = ["doc_print"] +evidence = ["crates/print/src/tests.rs::page_selection", "crates/automation/tests/automation.rs::printing_through_tools"] +notes = "all, current, ranges with labels and open ends" [[feature]] id = "print.odd-even-reverse" @@ -7815,10 +7815,10 @@ title = "Odd/even pages and reverse order" area = "L" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] +status = "shipped" +commands = ["print.dialog"] +tools = ["doc_print"] +evidence = ["crates/print/src/tests.rs::page_selection"] notes = "" [[feature]] @@ -7827,11 +7827,11 @@ title = "Fit, actual size, shrink oversized, custom scale" area = "L" tier = "P0" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["print.dialog"] +tools = ["doc_print"] +evidence = ["crates/print/src/tests.rs::size_modes"] +notes = "fit, actual size, shrink oversized, custom %" [[feature]] id = "print.paper-source-by-size" @@ -7851,11 +7851,11 @@ title = "Print on both sides" area = "L" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["print.dialog"] +tools = ["doc_print"] +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"] +notes = "long/short edge via the spooler" [[feature]] id = "print.n-up" @@ -7863,11 +7863,11 @@ title = "Multiple pages per sheet" area = "L" tier = "P0" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["print.dialog"] +tools = ["doc_print"] +evidence = ["crates/print/src/tests.rs::multiple_pages_per_sheet", "crates/automation/tests/automation.rs::printing_through_tools"] +notes = "2/4/6/9/16, four orders, borders, auto-rotate" [[feature]] id = "print.booklet" @@ -7875,11 +7875,11 @@ title = "Booklet printing" area = "L" tier = "P0" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["print.dialog"] +tools = ["doc_print"] +evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding"] +notes = "both/front/back, left/right binding" [[feature]] id = "print.poster" @@ -7887,11 +7887,11 @@ title = "Poster / tiled printing with overlap and cut marks" area = "L" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["print.dialog"] +tools = ["doc_print"] +evidence = ["crates/print/src/tests.rs::posters_tile_with_overlap", "crates/automation/tests/automation.rs::printing_through_tools"] +notes = "tile scale, overlap, cut marks (labels pending)" [[feature]] id = "print.orientation" @@ -7899,11 +7899,11 @@ title = "Auto portrait/landscape" area = "L" tier = "P0" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["print.dialog"] +tools = ["doc_print"] +evidence = ["crates/print/src/tests.rs::size_modes"] +notes = "auto portrait/landscape" [[feature]] id = "print.comments-and-forms" @@ -7911,11 +7911,11 @@ title = "Print document, markups, stamps or form fields only" area = "L" tier = "P0" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["print.dialog"] +tools = ["doc_print"] +evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms"] +notes = "Print flag honoured" [[feature]] id = "print.print-with-comment-summary" @@ -7935,11 +7935,11 @@ title = "Print preview" area = "L" tier = "P0" milestone = "M10" -status = "planned" -commands = [] +status = "shipped" +commands = ["print.dialog"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf"] +notes = "live sheet preview with sheet navigation" [[feature]] id = "print.grayscale" @@ -7947,11 +7947,11 @@ title = "Print in grayscale" area = "L" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["print.dialog"] +tools = ["doc_print"] +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"] +notes = "asks the printer for monochrome; the PDF itself stays in colour" [[feature]] id = "print.print-as-image" @@ -7983,11 +7983,11 @@ title = "Produce a print-ready imposed PDF" area = "L" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["print.dialog"] +tools = ["doc_print"] +evidence = ["crates/ui-egui/tests/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/automation/tests/automation.rs::printing_through_tools", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render"] +notes = "Save as PDF from the dialog" [[feature]] id = "print.print-pages-from-thumbnails"