M10.5: Print: new print crate (L4) imposes the pages to print onto sheets (Fit/Actual/Shrink/Custom, Multiple pages per sheet with order, borders and auto-rotate, Booklet, Poster with overlap and cut marks, orientation, Comments & Forms honouring the Print flag) as a fresh print-ready PDF; page ranges with labels, odd/even, reverse; CUPS spooler (printers, copies, collation, duplex, monochrome). Print dialog (⌘P) with a live sheet preview from page thumbnails and Save as PDF; print permission enforced; printers/doc_print tools (61 tools)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 00:40:57 -04:00
co-authored by Claude Opus 5.5
parent ff3dd114d6
commit 2aa29254da
25 changed files with 1899 additions and 76 deletions
Generated
+12
View File
@@ -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",
+1
View File
@@ -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" }
+3
View File
@@ -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)?,
+125
View File
@@ -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<String>) -> ToolError {
ToolError::InvalidArgs(m.into())
}
impl Automation {
pub(crate) fn printers(&self) -> Result<Value> {
let list: Vec<Value> = 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<Value> {
let doc = self.doc(a)?;
let (id, count) = (doc.id, doc.info.pages.len());
let labels: Vec<String> = 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)
}
}
+34
View File
@@ -307,6 +307,40 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["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"])),
+17
View File
@@ -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(_))));
}
+1
View File
@@ -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 }
+1
View File
@@ -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"),
+22
View File
@@ -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<Vec<u8>, 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<Vec<u8>>)]) -> Result<Arc<Vec<u8>>, EditError> {
let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::<Result<Vec<_>, _>>()?;
+25
View File
@@ -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);
+15
View File
@@ -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 }
+32
View File
@@ -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.
+590
View File
@@ -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<usize>,
/// 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<Placement>,
/// 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<Vec<Sheet>, PrintError> {
let pages: Vec<usize> = 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<u8> {
let list: Vec<Object> = 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<ObjRef> {
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<Matrix> {
let nums =
|k: &[u8]| -> Option<Vec<f64>> { 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<ObjRef, PrintError> {
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<f64> =
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<Vec<u8>, 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<usize, ObjRef> = 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<Vec<usize>> {
sheets.iter().map(|s| s.placed.iter().map(|p| p.page).collect()).collect()
}
+65
View File
@@ -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<usize, PrintError> {
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::<usize>() {
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<Vec<usize>, 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<usize> = 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)
}
+111
View File
@@ -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<String>,
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<Printer> {
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<String> {
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<Printer> {
#[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<String, PrintError> {
#[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()))
}
}
+193
View File
@@ -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<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
let kids: Vec<String> = (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<usize>, 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<String> = ["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<String> {
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");
}
+1
View File
@@ -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"] }
+6 -1
View File
@@ -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<egui::TextureId> {
self.thumbs.get(&page).map(|t| t.id())
}
pub(crate) fn page_text(&self, page: usize) -> Option<Arc<PageText>> {
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];
+1 -1
View File
@@ -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() {
+1
View File
@@ -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);
+24 -1
View File
@@ -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<Dialog> = 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<String> = doc.info.pages.iter().map(|p| p.label.clone()).collect();
let thumbs: std::collections::HashMap<usize, egui::TextureId> =
(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) => {
+6
View File
@@ -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<files::ReplaceDraft>,
/// 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 },
}
+488
View File
@@ -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<spool::Printer>,
/// `None` = Save as PDF.
pub printer: Option<String>,
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<print::Settings, String> {
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<String> = 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<T: PartialEq + Copy>(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<egui::TextureId>,
) -> (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)
}
+52
View File
@@ -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");
}
+73 -73
View File
@@ -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"