Files
wordcraft/crates/engine/src/print.rs
T

426 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(&params).unwrap();
assert!(!links(&job) && !String::from_utf8_lossy(&job).contains("/Annots"), "{params}");
let (saved, _) = s.print_pdf(&params).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(&params, &lay, s.current_pages(&lay)).unwrap().len(), sides);
let (bytes, plan) = s.print_pdf(&params).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(&params).is_err());
}
assert_eq!((s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap()), before);
}
}