mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
ff3dd114d6
commit
2aa29254da
Generated
+12
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"])),
|
||||
|
||||
@@ -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(_))));
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<_>, _>>()?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user