mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-07 19:23:35 +00:00
426 lines
21 KiB
Rust
426 lines
21 KiB
Rust
//! Print settings (#641): which pages go out, how many copies, as a PDF.
|
||
//!
|
||
//! WordCraft prints through a PDF (the host opens it in the system's print flow), so the settings
|
||
//! that can be delivered are the ones that shape the PDF: the page range, odd/even pages, copies
|
||
//! and collation. Physical sheet imposition is shared with the preview; printer choice and duplex
|
||
//! are handled by the host.
|
||
//!
|
||
//! Page ranges and copies come from users and control channels, so they are bounded: a range
|
||
//! text is at most [`MAX_SPEC_CHARS`] long, copies are clamped to [`MAX_COPIES`], and the
|
||
//! resulting page list never exceeds [`MAX_PRINT_PAGES`].
|
||
|
||
use serde_json::{Value, json};
|
||
use wordcraft_layout::DocLayout;
|
||
pub use wordcraft_pdf::imposition::Imposition;
|
||
|
||
use crate::{CmdError, Session, p};
|
||
|
||
/// Longest page-range text accepted.
|
||
pub const MAX_SPEC_CHARS: usize = 500;
|
||
/// Most copies one print job asks for.
|
||
pub const MAX_COPIES: u32 = 999;
|
||
/// Most pages (counting copies) one print job holds.
|
||
pub const MAX_PRINT_PAGES: usize = 5000;
|
||
|
||
/// Parses a custom page range like `1-3, 5, 8-` into 0-based page indexes, in the order typed
|
||
/// (Word prints them in document order; so does this: sorted, without repeats). Pages are
|
||
/// 1-based in the text; `8-` means "to the last page" and `-3` "from the first". An empty text,
|
||
/// page 0, a page past `total`, a reversed range or anything else is an error.
|
||
pub fn parse_page_range(spec: &str, total: usize) -> Result<Vec<usize>, String> {
|
||
if spec.chars().count() > MAX_SPEC_CHARS {
|
||
return Err("the page range is too long".into());
|
||
}
|
||
if total == 0 {
|
||
return Err("the document has no pages".into());
|
||
}
|
||
let mut keep = vec![false; total];
|
||
let mut any = false;
|
||
for item in spec.split([',', ';']) {
|
||
let item: String = item.chars().filter(|c| !c.is_whitespace()).collect();
|
||
if item.is_empty() {
|
||
continue;
|
||
}
|
||
let page = |t: &str| -> Result<usize, String> {
|
||
let not_a_page = || format!("`{item}` isn't a page or a range like 2-4");
|
||
if !t.bytes().all(|b| b.is_ascii_digit()) {
|
||
return Err(not_a_page());
|
||
}
|
||
let n: usize = t.parse().map_err(|_| not_a_page())?;
|
||
if n == 0 || n > total {
|
||
return Err(format!("page {n} doesn't exist; the document has {total} page{}", if total == 1 { "" } else { "s" }));
|
||
}
|
||
Ok(n)
|
||
};
|
||
let (from, to) = match item.split_once('-') {
|
||
None => {
|
||
let n = page(&item)?;
|
||
(n, n)
|
||
}
|
||
Some(("", "")) => return Err("`-` isn't a page or a range like 2-4".into()),
|
||
Some((a, b)) => {
|
||
let from = if a.is_empty() { 1 } else { page(a)? };
|
||
let to = if b.is_empty() { total } else { page(b)? };
|
||
if from > to {
|
||
return Err(format!("`{item}` runs backwards"));
|
||
}
|
||
(from, to)
|
||
}
|
||
};
|
||
for flag in keep.iter_mut().take(to).skip(from - 1) {
|
||
*flag = true;
|
||
}
|
||
any = true;
|
||
}
|
||
if !any {
|
||
return Err("type the pages to print, like 1-3, 5".into());
|
||
}
|
||
Ok(keep.iter().enumerate().filter(|(_, k)| **k).map(|(i, _)| i).collect())
|
||
}
|
||
|
||
/// What a print job holds: the pages (0-based, copies included, in print order).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct PrintPlan {
|
||
/// Page indexes in print order, repeated for copies.
|
||
pub pages: Vec<usize>,
|
||
/// Distinct pages chosen (before copies).
|
||
pub chosen: usize,
|
||
pub copies: u32,
|
||
}
|
||
|
||
/// Works out the print plan for `v` against a layout of `total` pages whose caret and selection
|
||
/// sit on pages `current` (first, last). Params:
|
||
/// `range` (`all` | `current` | `selection` | `custom`; `custom` is implied by `pages`),
|
||
/// `pages` (text like `1-3,5`), `parity` (`all` | `odd` | `even`), `copies` (1–999),
|
||
/// `collate` (default true: 1,2,3 1,2,3; false: 1,1,2,2,3,3).
|
||
pub fn plan(v: &Value, total: usize, current: (usize, usize)) -> Result<PrintPlan, CmdError> {
|
||
let bad = |m: String| CmdError::Params(m);
|
||
let pages_text = p::str(v, "pages").filter(|t| !t.trim().is_empty());
|
||
let range = p::str(v, "range").unwrap_or(if pages_text.is_some() { "custom" } else { "all" });
|
||
let last = total.saturating_sub(1);
|
||
let mut pages: Vec<usize> = match range {
|
||
"all" => (0..total).collect(),
|
||
"current" => vec![current.0.min(last)],
|
||
"selection" => (current.0.min(last)..=current.1.min(last).max(current.0.min(last))).collect(),
|
||
"custom" => parse_page_range(pages_text.unwrap_or(""), total).map_err(bad)?,
|
||
other => return Err(bad(format!("unknown range `{other}`; use all, current, selection or custom"))),
|
||
};
|
||
match p::str(v, "parity").unwrap_or("all") {
|
||
"all" => {}
|
||
// Page numbers are 1-based: the odd ones are indexes 0, 2, 4…
|
||
"odd" => pages.retain(|i| i % 2 == 0),
|
||
"even" => pages.retain(|i| i % 2 == 1),
|
||
other => return Err(bad(format!("unknown parity `{other}`; use all, odd or even"))),
|
||
}
|
||
if pages.is_empty() {
|
||
return Err(bad("no pages to print with these settings".into()));
|
||
}
|
||
let copies = p::u64(v, "copies").unwrap_or(1).clamp(1, u64::from(MAX_COPIES)) as u32;
|
||
let chosen = pages.len();
|
||
let total_out = chosen.checked_mul(copies as usize).filter(|n| *n <= MAX_PRINT_PAGES);
|
||
let Some(total_out) = total_out else {
|
||
return Err(bad(format!("{chosen} pages × {copies} copies is more than the {MAX_PRINT_PAGES} pages one print job can hold")));
|
||
};
|
||
let mut out = Vec::with_capacity(total_out);
|
||
if p::bool(v, "collate").unwrap_or(true) {
|
||
for _ in 0..copies {
|
||
out.extend_from_slice(&pages);
|
||
}
|
||
} else {
|
||
for &pg in &pages {
|
||
out.extend(std::iter::repeat_n(pg, copies as usize));
|
||
}
|
||
}
|
||
Ok(PrintPlan { pages: out, chosen, copies })
|
||
}
|
||
|
||
/// The `paper` param of a print job: the standard paper (see [`wordcraft_geom::PAPER_SIZES`]) to
|
||
/// scale every page to (Scale to paper size, #2108), or `None` to print the document's own page
|
||
/// sizes (absent, null, `""`, `none` or `document`). Any other value is an error.
|
||
pub fn paper(v: &Value) -> Result<Option<(&'static str, f32, f32)>, CmdError> {
|
||
match v.get("paper") {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(Value::String(name)) if name.trim().is_empty() || ["none", "document"].contains(&name.trim().to_ascii_lowercase().as_str()) => Ok(None),
|
||
Some(Value::String(name)) => wordcraft_geom::paper_by_name(name).map(Some).ok_or_else(|| {
|
||
let names: Vec<&str> = wordcraft_geom::PAPER_SIZES.iter().map(|(n, _, _)| *n).collect();
|
||
CmdError::Params(format!("unknown paper `{}`; use none or one of {}", name.chars().take(40).collect::<String>(), names.join(", ")))
|
||
}),
|
||
Some(_) => Err(CmdError::Params("paper must be a paper size name or none".into())),
|
||
}
|
||
}
|
||
|
||
/// Validate the physical sheet layout without touching document page setup.
|
||
pub fn imposition(v: &Value) -> Result<wordcraft_pdf::imposition::Imposition, CmdError> {
|
||
match v.get("pagesPerSheet") {
|
||
None => Ok(Imposition::Single),
|
||
Some(Value::Number(n)) => match n.as_u64() {
|
||
Some(1) => Ok(Imposition::Single),
|
||
Some(n @ (2 | 4 | 6 | 9 | 16)) => Ok(Imposition::NUp(n as u8)),
|
||
_ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())),
|
||
},
|
||
Some(Value::String(s)) if s == "booklet" || s == "bookletReverse" => Ok(Imposition::Booklet { reverse: s == "bookletReverse" }),
|
||
_ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())),
|
||
}
|
||
}
|
||
|
||
/// Resolve physical paper orientation for imposed jobs; ordinary export keeps its existing policy.
|
||
fn imposed_paper(v: &Value, lay: &DocLayout, pages: &[usize]) -> Result<Option<(f32, f32)>, CmdError> {
|
||
let paper = paper(v)?.map(|(_, w, h)| (w, h));
|
||
if imposition(v)? == Imposition::Single {
|
||
return Ok(paper);
|
||
}
|
||
let own = pages.first().and_then(|p| lay.pages.get(*p)).map(|p| (p.w - p.markup, p.h));
|
||
let Some((w, h)) = paper.or(own) else { return Ok(paper) };
|
||
match v.get("orientation").and_then(Value::as_str).unwrap_or("auto") {
|
||
"auto" => Ok(paper),
|
||
"portrait" => Ok(Some((w.min(h), w.max(h)))),
|
||
"landscape" => Ok(Some((w.max(h), w.min(h)))),
|
||
_ => Err(CmdError::Params("orientation must be auto, portrait or landscape".into())),
|
||
}
|
||
}
|
||
|
||
/// Plan physical sheet sides for one copy, using the same selection and geometry as printing.
|
||
pub fn sheets(v: &Value, lay: &DocLayout, current: (usize, usize)) -> Result<Vec<wordcraft_pdf::imposition::Sheet>, CmdError> {
|
||
if !v.is_object() {
|
||
return Err(CmdError::Params("print settings must be an object".into()));
|
||
}
|
||
let mut single = v.clone();
|
||
single["copies"] = json!(1);
|
||
let pages = plan(&single, lay.pages.len(), current)?.pages;
|
||
imposition(v)?.sheets(lay, &pages, imposed_paper(v, lay, &pages)?).map_err(|e| CmdError::Failed(e.to_string()))
|
||
}
|
||
|
||
impl Session {
|
||
/// The pages the caret (or selection) is on, in the output layout: (first, last).
|
||
pub fn current_pages(&self, lay: &DocLayout) -> (usize, usize) {
|
||
let (a, b) = self.sel.ordered();
|
||
let first = lay.caret_on(&a, self.page_hint).map_or(0, |c| c.page);
|
||
let last = lay.caret_on(&b, self.page_hint).map_or(first, |c| c.page);
|
||
(first, last.max(first))
|
||
}
|
||
|
||
/// The document as the PDF of a print job `v` describes (see [`plan`]), with the number of
|
||
/// distinct pages chosen and copies, as File › Print › Save these pages as PDF keeps it:
|
||
/// links work. Does not touch the document.
|
||
pub fn print_pdf(&self, v: &Value) -> Result<(Vec<u8>, PrintPlan), CmdError> {
|
||
self.print_pdf_with(v, true)
|
||
}
|
||
|
||
/// The PDF sent to a printer for job `v`: [`Self::print_pdf`] without link annotations,
|
||
/// which print nothing, and which a printer driver can't fit to the paper ("flatten page
|
||
/// annotations before using shrink-to-fit", #4734).
|
||
pub fn print_job_pdf(&self, v: &Value) -> Result<(Vec<u8>, PrintPlan), CmdError> {
|
||
self.print_pdf_with(v, false)
|
||
}
|
||
|
||
/// Render a shared sheet plan for a saved PDF or an annotation-free printer job.
|
||
fn print_pdf_with(&self, v: &Value, links: bool) -> Result<(Vec<u8>, PrintPlan), CmdError> {
|
||
if !v.is_object() {
|
||
return Err(CmdError::Params("print settings must be an object".into()));
|
||
}
|
||
// Print Markup (File › Print) decides whether tracked changes and comments print.
|
||
let markup = crate::cmd::file::export_options(self, v);
|
||
let out = crate::cmd::references::output_fields(&self.doc);
|
||
let doc = out.as_ref().unwrap_or(&self.doc);
|
||
let lay = self.export_layout_of(doc, markup.markup);
|
||
let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?;
|
||
let mut single = v.clone();
|
||
single["copies"] = json!(1);
|
||
let single_plan = self::plan(&single, lay.pages.len(), self.current_pages(&lay))?;
|
||
let paper = imposed_paper(v, &lay, &single_plan.pages)?;
|
||
let opts = wordcraft_pdf::PdfOptions {
|
||
pages: Some(single_plan.pages),
|
||
imposition: imposition(v)?,
|
||
sheet_copies: plan.copies,
|
||
sheet_collate: p::bool(v, "collate").unwrap_or(true),
|
||
// Repeated pages would repeat the structure tree's entries too.
|
||
tagged: plan.copies <= 1,
|
||
include_markup: markup.markup,
|
||
comments: markup.comments,
|
||
paper,
|
||
links,
|
||
..Default::default()
|
||
};
|
||
let bytes = wordcraft_pdf::export_layout(doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||
Ok((bytes, plan))
|
||
}
|
||
}
|
||
|
||
/// `file.printPdf`: write the print job's PDF to `path` (no dialogs).
|
||
pub(crate) fn print_pdf_to_file(s: &mut Session, v: &Value) -> Result<Value, CmdError> {
|
||
let path = p::req_str(v, "path")?;
|
||
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
|
||
let (bytes, plan) = s.print_pdf(v)?;
|
||
// Through `io`, so an agent confined to an automation write root (#769) can't print elsewhere.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
crate::io::write_file(&path, &bytes).map_err(CmdError::Failed)?;
|
||
Ok(json!({"path": path, "pages": plan.pages.len(), "chosen": plan.chosen, "copies": plan.copies, "bytes": bytes.len()}))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn ranges_parse_and_hostile_ones_are_errors() {
|
||
assert_eq!(parse_page_range("1-3, 5", 6), Ok(vec![0, 1, 2, 4]));
|
||
assert_eq!(parse_page_range("5,1-2,2", 6), Ok(vec![0, 1, 4]), "document order, no repeats");
|
||
assert_eq!(parse_page_range("4-", 6), Ok(vec![3, 4, 5]));
|
||
assert_eq!(parse_page_range("-2", 6), Ok(vec![0, 1]));
|
||
for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", "+3", "1-+3", "+2-+4", &"1,".repeat(400)] {
|
||
assert!(parse_page_range(bad, 6).is_err(), "`{bad}` is refused");
|
||
}
|
||
assert!(parse_page_range("1", 0).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn plans_apply_range_parity_copies_and_caps() {
|
||
let p = |v: Value| plan(&v, 6, (2, 3)).map(|p| p.pages);
|
||
assert_eq!(p(json!({})), Ok(vec![0, 1, 2, 3, 4, 5]));
|
||
assert_eq!(p(json!({"range": "current"})), Ok(vec![2]));
|
||
assert_eq!(p(json!({"range": "selection"})), Ok(vec![2, 3]));
|
||
assert_eq!(p(json!({"pages": "2-4", "copies": 2})), Ok(vec![1, 2, 3, 1, 2, 3]));
|
||
assert_eq!(p(json!({"pages": "2-3", "copies": 2, "collate": false})), Ok(vec![1, 1, 2, 2]));
|
||
assert_eq!(p(json!({"parity": "even"})), Ok(vec![1, 3, 5]));
|
||
assert!(p(json!({"range": "custom"})).is_err(), "custom needs pages");
|
||
assert!(p(json!({"range": "everything"})).is_err());
|
||
assert!(p(json!({"range": "current", "parity": "even"})).is_err(), "page 3 is odd: nothing left");
|
||
// Hostile copies are clamped; a job that would be too big is refused, not allocated.
|
||
assert_eq!(p(json!({"range": "current", "copies": 1e12})).map(|v| v.len()), Ok(MAX_COPIES as usize));
|
||
assert!(plan(&json!({"copies": 999}), 600, (0, 0)).is_err());
|
||
assert_eq!(p(json!({"copies": -5, "range": "current"})), Ok(vec![2]));
|
||
}
|
||
|
||
#[test]
|
||
fn print_pdf_writes_only_the_chosen_pages() {
|
||
let mut s = Session::new(crate::sample::sample_document());
|
||
let pages = s.export_layout().pages.len();
|
||
let (all, _) = s.print_pdf(&json!({})).unwrap();
|
||
let (one, plan) = s.print_pdf(&json!({"range": "current", "copies": 2})).unwrap();
|
||
assert!(all.starts_with(b"%PDF") && one.starts_with(b"%PDF"));
|
||
assert_eq!(plan.pages.len(), 2);
|
||
if pages > 2 {
|
||
assert!(one.len() < all.len());
|
||
}
|
||
assert!(s.print_pdf(&json!({"pages": "9999"})).is_err());
|
||
let r = s.run("file.printPdf", &json!({})).is_err();
|
||
assert!(r, "printPdf needs a path");
|
||
}
|
||
|
||
/// #4734: the PDF sent to a printer has no link annotations, which a printer driver can't fit
|
||
/// to the paper ("flatten page annotations before using shrink-to-fit"). An exported PDF and
|
||
/// one saved with Save these pages as PDF (`file.printPdf`) keep them.
|
||
#[test]
|
||
fn only_the_print_job_leaves_out_link_annotations() {
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("insert.link", &json!({"url": "https://example.com", "text": "a link"})).unwrap();
|
||
let lay = s.export_layout();
|
||
let exported = wordcraft_pdf::export_layout(&s.doc, &lay, &wordcraft_pdf::PdfOptions::default()).unwrap();
|
||
let links = |bytes: &[u8]| {
|
||
let raw = String::from_utf8_lossy(bytes);
|
||
raw.contains("/Annots") && raw.contains("/Link")
|
||
};
|
||
assert!(links(&exported), "an exported PDF keeps the link");
|
||
for params in [json!({}), json!({"paper": "A4"}), json!({"markup": true})] {
|
||
let (job, _) = s.print_job_pdf(¶ms).unwrap();
|
||
assert!(!links(&job) && !String::from_utf8_lossy(&job).contains("/Annots"), "{params}");
|
||
let (saved, _) = s.print_pdf(¶ms).unwrap();
|
||
assert!(links(&saved), "{params}");
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let path = std::env::temp_dir().join(format!("wc-print-links-{}.pdf", std::process::id()));
|
||
s.run("file.printPdf", &json!({"path": path.to_string_lossy()})).unwrap();
|
||
let saved = std::fs::read(&path).unwrap();
|
||
let _ = std::fs::remove_file(&path);
|
||
assert!(links(&saved), "Save these pages as PDF keeps the link");
|
||
}
|
||
}
|
||
|
||
/// Scale to paper size (#2108): the PDF goes on the chosen paper; the document keeps its size.
|
||
#[test]
|
||
fn print_pdf_scales_to_the_chosen_paper() {
|
||
// Page dictionaries are written uncompressed: `/MediaBox[0 0 w h]`.
|
||
let media = |bytes: &[u8]| {
|
||
let raw = String::from_utf8_lossy(bytes).into_owned();
|
||
raw.split("/MediaBox[")
|
||
.skip(1)
|
||
.filter_map(|r| r.split(']').next())
|
||
.map(|r| r.split_whitespace().filter_map(|n| n.parse::<f32>().ok()).collect::<Vec<_>>())
|
||
.map(|n| (n[2].round(), n[3].round()))
|
||
.collect::<Vec<_>>()
|
||
};
|
||
let s = Session::new(crate::sample::sample_document());
|
||
let (w, h) = (s.doc.last_section.page_w, s.doc.last_section.page_h);
|
||
let rev = s.rev();
|
||
let (own, _) = s.print_pdf(&json!({"range": "current"})).unwrap();
|
||
assert_eq!(media(&own), vec![(w.round(), h.round())]);
|
||
for (name, want) in [("A4", (595.0, 842.0)), ("letter", (612.0, 792.0)), ("Legal", (612.0, 1008.0))] {
|
||
let (bytes, _) = s.print_pdf(&json!({"range": "current", "paper": name})).unwrap();
|
||
assert_eq!(media(&bytes), vec![want], "{name}");
|
||
}
|
||
for none in [json!(null), json!(""), json!("none"), json!("Document")] {
|
||
let (bytes, _) = s.print_pdf(&json!({"range": "current", "paper": none})).unwrap();
|
||
assert_eq!(media(&bytes), media(&own));
|
||
}
|
||
for bad in [json!("Quarto"), json!(5), json!({"w": 1}), json!("x".repeat(10_000))] {
|
||
assert!(s.print_pdf(&json!({"paper": bad})).is_err(), "{bad}");
|
||
}
|
||
assert_eq!((s.doc.last_section.page_w, s.doc.last_section.page_h, s.rev()), (w, h, rev), "the document is unchanged");
|
||
}
|
||
|
||
/// `file.printPdf` writes only inside the automation write root when there is one (#769).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn print_pdf_to_file_is_held_to_the_write_root() {
|
||
use crate::file_access::{AutomationRoots, confine};
|
||
let base = std::env::temp_dir().join(format!("wc-print-roots-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
for d in ["out", "outside"] {
|
||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||
}
|
||
let roots = AutomationRoots::new(None, Some(&base.join("out"))).unwrap();
|
||
let mut s = Session::new(crate::sample::sample_document());
|
||
let inside = base.join("out/job").to_string_lossy().into_owned();
|
||
let outside = base.join("outside/job").to_string_lossy().into_owned();
|
||
confine(roots.as_ref(), || {
|
||
let e = s.run("file.printPdf", &json!({"path": outside})).unwrap_err().to_string();
|
||
assert!(e.contains("outside the write root"), "{e}");
|
||
s.run("file.printPdf", &json!({"path": inside})).unwrap();
|
||
});
|
||
assert!(!base.join("outside/job.pdf").exists(), "nothing printed outside the root");
|
||
assert!(std::fs::read(base.join("out/job.pdf")).unwrap().starts_with(b"%PDF"));
|
||
// Without roots nothing is checked: the behaviour is unchanged.
|
||
s.run("file.printPdf", &json!({"path": outside})).unwrap();
|
||
assert!(base.join("outside/job.pdf").is_file());
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod imposition_tests {
|
||
use super::*;
|
||
/// Sheet printing leaves document bytes, dirty state, selection and undo history untouched.
|
||
#[test]
|
||
fn print_imposition_is_pure_and_rejects_hostile_modes() {
|
||
let s = Session::new(wordcraft_doc::Document::from_text("one\u{c}two\u{c}three\u{c}four\u{c}five"));
|
||
let before = (s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap());
|
||
for (mode, sides) in
|
||
[(json!(2), 3), (json!(4), 2), (json!(6), 1), (json!(9), 1), (json!(16), 1), (json!("booklet"), 4), (json!("bookletReverse"), 4)]
|
||
{
|
||
let params = json!({"pagesPerSheet":mode, "copies":2});
|
||
let lay = s.export_layout();
|
||
assert_eq!(sheets(¶ms, &lay, s.current_pages(&lay)).unwrap().len(), sides);
|
||
let (bytes, plan) = s.print_pdf(¶ms).unwrap();
|
||
assert_eq!(plan.copies, 2);
|
||
assert_eq!(String::from_utf8_lossy(&bytes).matches("/MediaBox[").count(), sides * 2);
|
||
}
|
||
for bad in [json!(0), json!(3), json!(-1), json!(1.5), json!(true), json!(null), json!({}), json!("2"), json!("x".repeat(10000))] {
|
||
assert!(s.print_pdf(&json!({"pagesPerSheet":bad})).is_err());
|
||
}
|
||
for params in [json!(null), json!([]), json!(false)] {
|
||
assert!(s.print_pdf(¶ms).is_err());
|
||
}
|
||
assert_eq!((s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap()), before);
|
||
}
|
||
}
|