From ccb1eae6134ff08148d218073d7a5d41b590571e Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Tue, 13 Oct 2026 01:47:19 +0200 Subject: [PATCH] fix(print): refuse fitting that would discard annotations --- crates/print/README.md | 4 ++++ crates/print/src/sheets.rs | 9 ++++++++ crates/print/src/tests/windows_native.rs | 28 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/crates/print/README.md b/crates/print/README.md index 28fabea9..1277aae9 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -61,6 +61,10 @@ a driver paper-size mismatch. On Windows `command_preview` describes the in-proc (`xps-print`, then the printer and options as `key=value`) instead of a command line. The crate does not provide PostScript output, separations, trapping or colour-managed device rendering. +Windows shrink-to-fit refuses pages with annotations when their geometry needs fitting. +Callers must flatten annotation appearances into page content first. Exact-size printing and +rotation retain the annotations. This avoids losing printed comments, forms or other marks. + `cargo test -p craft-print` tests CUPS transport (including pipe-sized jobs and refusal), printer parsing, PPD options and argument mapping, and on every platform the Windows pieces that need no printer: the PrintTicket, the checks on the driver's validated ticket and capabilities, diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index 7b783980..7ba70e1a 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -1068,6 +1068,15 @@ impl Sheets<'_> { if get(&self.trailer, b"Encrypt").is_some() { return Err(refuse("it is encrypted")); } + // Annotation appearances live outside /Contents. Transforming only content + // would leave them at the old coordinates; deleting them loses printed marks. + // Require callers to flatten before fitting until appearances can be reframed. + if let Some(annotations) = get(&dict, b"Annots") + && !matches!(annotations, Obj::Null) + && !matches!(annotations, Obj::Array(items) if items.is_empty()) + { + return Err(refuse("flatten page annotations before using shrink-to-fit")); + } let [a, b, c, d, e, f] = frame.matrix; let [x, y, w, h] = frame.clip; let prefix = format!( diff --git a/crates/print/src/tests/windows_native.rs b/crates/print/src/tests/windows_native.rs index 197f8cec..6b226595 100644 --- a/crates/print/src/tests/windows_native.rs +++ b/crates/print/src/tests/windows_native.rs @@ -49,6 +49,10 @@ const TABLOID: TestPage = TestPage { media: Some([0.0, 0.0, 792.0, 1224.0]), cro /// cross-reference stream, as modern writers save. The root page node sets a Letter MediaBox /// for pages that inherit it. fn test_pdf(pages: &[TestPage], compressed: bool) -> Vec { + test_pdf_with_annotations(pages, compressed, None) +} + +fn test_pdf_with_annotations(pages: &[TestPage], compressed: bool, annotations: Option<&str>) -> Vec { let n = pages.len(); // 1 catalog, 2 page tree, 3 content, then the pages. let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + i)).collect(); @@ -69,6 +73,9 @@ fn test_pdf(pages: &[TestPage], compressed: bool) -> Vec { if let Some(r) = p.rotate { page += &format!(" /Rotate {r}"); } + if let Some(annotations) = annotations { + page += &format!(" /Annots {annotations}"); + } objects.push(page + " >>"); } let mut pdf = b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n".to_vec(); @@ -264,6 +271,27 @@ fn shrink_to_fit_reframes_sheets_on_the_paper() { assert_eq!(shapes(&sheets.update(&edits).unwrap()), [((612, 792), 0), ((612, 792), 270), ((612, 792), 0)]); } +#[test] +fn shrink_to_fit_refuses_to_drop_annotation_appearances() { + use crate::sheets::{Fit, plan, read}; + for annotations in ["[<< /Subtype /Text /Rect [36 36 72 72] /F 4 >>]", "42 0 R"] { + let pdf = test_pdf_with_annotations(&[LETTER, LETTER_LANDSCAPE], false, Some(annotations)); + let sheets = read(&pdf).unwrap(); + let fit = plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 18.0 }).unwrap(); + assert!(matches!(sheets.update(&fit), Err(Error::Spool(ref m)) if m.contains("flatten page annotations"))); + let exact = plan(&sheets.sheets, (612.0, 792.0), Fit::Exact).unwrap(); + let rotated = sheets.update(&exact).unwrap(); + assert!(rotated.starts_with(&pdf)); + assert!(String::from_utf8_lossy(&rotated[pdf.len()..]).contains("/Annots"), "rotation retains annotations on the updated page"); + } + for annotations in ["[]", "null"] { + let pdf = test_pdf_with_annotations(&[LETTER, LETTER_LANDSCAPE], false, Some(annotations)); + let sheets = read(&pdf).unwrap(); + let fit = plan(&sheets.sheets, (612.0, 792.0), Fit::Shrink { margin: 18.0 }).unwrap(); + assert!(sheets.update(&fit).is_ok()); + } +} + #[test] fn an_encrypted_file_is_turned_but_not_reframed() { // An /Encrypt entry is all it takes: new content streams would have to be encrypted too.