From 41430cc1712f2b2b7f47fc1747922a8eb91ec85e Mon Sep 17 00:00:00 2001 From: Chris Main Date: Fri, 23 Oct 2026 21:10:01 -0400 Subject: [PATCH] Print: landscape pages print upright on macOS print queues A landscape print job was a page wider than tall, and `lp` on macOS puts each page on the paper as it is: on upright Letter it printed unturned, cut off on the right (checked with cupsfilter through an AirPrint driver). Saving the PDF and printing it from Preview worked because Preview turns the page. `file.print` takes `forPrinter`: a page wider than tall is written upright, the art turned a quarter anticlockwise as a printer driver turns a landscape job. Page by page, so auto-rotated jobs that mix wide and tall artboards print right too. The desktop print service asks for it (`PrintService::turns_landscape`); saved PDFs, PostScript, the preview and the browser's print dialog keep landscape pages as they are. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/vectorcraft/src/printing.rs | 4 +++ crates/engine/src/cmd/print.rs | 6 ++-- crates/engine/src/tests_print.rs | 12 +++++++ crates/pdf/src/print/mod.rs | 25 ++++++++++++-- crates/pdf/src/tests_print.rs | 40 +++++++++++++++++++++++ crates/ui-egui/src/dialogs/tests_print.rs | 34 +++++++++++++++++++ crates/ui-egui/src/print.rs | 9 +++++ 7 files changed, 126 insertions(+), 4 deletions(-) diff --git a/apps/vectorcraft/src/printing.rs b/apps/vectorcraft/src/printing.rs index 643e64c69..8f8d30a34 100644 --- a/apps/vectorcraft/src/printing.rs +++ b/apps/vectorcraft/src/printing.rs @@ -23,6 +23,10 @@ impl PrintService for SystemPrint { fn shared_print(&self) -> Option { Some(std::sync::Arc::new(|job| SystemPrint.print(job))) } + fn turns_landscape(&self) -> bool { + // `lp` doesn't turn a landscape page to the paper (macOS cuts it off on upright paper). + false + } fn has_setup(&self) -> bool { true } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 76f52f4b8..a19ef1ae2 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Print", [], Some("Cmd+P"), - "{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", + "{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out), forPrinter?: bool (the PDF goes to a print queue: a page wider than tall is written upright on the paper, the art turned a quarter anticlockwise, as a printer driver turns a landscape job; macOS prints a landscape page as it is, cut off on upright paper)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", has_doc, print ), @@ -187,7 +187,9 @@ fn print(s: &mut Session, p: &Value) -> Result { if !postscript { let printed = prepared.as_ref().unwrap_or(doc); let typed = type_warnings(&full, which, &areas(&vectorcraft_pdf::preview(printed, &set).map_err(|e| pdf_error(PRINT, e))?)); - let mut r = vectorcraft_pdf::print(printed, &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(PRINT, e))?; + let mut r = + vectorcraft_pdf::print(printed, &PrintOptions { settings: set, for_printer: bool_or(p, "forPrinter", false), ..Default::default() }) + .map_err(|e| pdf_error(PRINT, e))?; r.warnings.extend(typed); return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings })); } diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index c92e3bf5a..4ba587d45 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -212,3 +212,15 @@ fn type_that_does_not_fit_its_frame_is_named() { let (_, v) = print(&mut s, json!({})); assert_eq!(over(&v), Vec::::new()); } + +#[test] +fn for_a_printer_a_landscape_page_is_written_upright() { + let (mut s, _) = session(); + let size = |pdf: &[u8]| vectorcraft_pdf::info(pdf, None).unwrap().pages.iter().map(|p| (p.width, p.height)).collect::>(); + let settings = json!({"autoRotate": false, "orientation": "landscape"}); + let (saved, _) = print(&mut s, json!({"settings": settings})); + assert_eq!(size(&saved), [(792.0, 612.0)]); + let (spooled, v) = print(&mut s, json!({"settings": settings, "forPrinter": true})); + assert_eq!(size(&spooled), [(612.0, 792.0)]); + assert_eq!(v["pages"], 1); +} diff --git a/crates/pdf/src/print/mod.rs b/crates/pdf/src/print/mod.rs index 56c8a2525..9ef36ff2e 100644 --- a/crates/pdf/src/print/mod.rs +++ b/crates/pdf/src/print/mod.rs @@ -67,6 +67,10 @@ pub struct PrintOptions { /// The job is written as PostScript, which carries halftone screens and flatness: no /// warnings that they are left to the device. pub postscript: bool, + /// The job goes to a print queue, which puts each page on the paper as it is (macOS does + /// not turn a landscape page to the paper): a page wider than tall is written upright, the + /// art turned a quarter anticlockwise, as a printer driver turns a landscape job. + pub for_printer: bool, } /// A printed job. @@ -375,7 +379,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result, } art }); - Some(PlannedSheet { + let sheet = PlannedSheet { size: l.size, trim: l.page_trim, bleed: l.page_bleed, @@ -387,13 +391,30 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result, negative: set.output.image == PrintImage::Negative, plate: *plate, ink: ink.cloned(), - }) + }; + Some(if opts.for_printer { upright(sheet) } else { sheet }) }) .collect(); let order = job.order(set); Ok(PrintPlan { plates, sheets, order, title, created, warnings: job.warnings, doc: job.doc }) } +/// `s` upright on the paper ([`PrintOptions::for_printer`]): a page wider than tall turns a +/// quarter anticlockwise, its top to the paper's left edge. +fn upright(mut s: PlannedSheet) -> PlannedSheet { + let (w, h) = s.size; + if w <= h { + return s; + } + // y down: the top-right corner (w, 0) goes to the top-left, the top-left to the bottom-left. + let turn = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, w]); + s.size = (h, w); + s.view = turn * s.view; + s.trim = turn.transform_rect_bbox(s.trim); + s.bleed = turn.transform_rect_bbox(s.bleed); + s +} + /// Print `doc` to a PDF as `opts` say. pub fn print(doc: &Document, opts: &PrintOptions) -> Result { craft_pdf::write::guard(|| leaving_out_undecodable(doc, |left_out| print_inner(doc, opts, left_out))) diff --git a/crates/pdf/src/tests_print.rs b/crates/pdf/src/tests_print.rs index d969dcacc..a37ea408e 100644 --- a/crates/pdf/src/tests_print.rs +++ b/crates/pdf/src/tests_print.rs @@ -441,6 +441,46 @@ fn turns_mirror_and_negative() { assert!(c[0] < 10 && c[1] > 245 && c[2] > 245, "{c:?}"); } +#[test] +fn for_a_printer_landscape_pages_print_upright_turned_anticlockwise() { + let d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 50.0, 100.0), rgb(1.0, 0.0, 0.0))]); + let s = json!({"placement": {"origin": "topLeft"}, "autoRotate": false, "orientation": "landscape"}); + let spooled = |v| { + let opts = PrintOptions { settings: settings(v), created: Some(1_791_200_000), uncompressed: true, for_printer: true, ..Default::default() }; + pages(&print(&d, &opts).unwrap().bytes).remove(0) + }; + // Saved, the page stays landscape. + assert_eq!(pages(&job(&d, s.clone()).bytes)[0].0, (792.0, 612.0)); + // For a printer, Letter upright: the art's top at the paper's left edge, its left half at the bottom. + let p = spooled(s.clone()); + assert_eq!(p.0, (612.0, 792.0)); + assert!(near(p.1[0].0, Rect::new(0.0, 742.0, 100.0, 792.0)), "{:?}", p.1); + // A portrait page and a transverse one (already upright) don't turn. + let p = spooled(json!({"placement": {"origin": "topLeft"}, "autoRotate": false})); + assert_eq!(p.0, (612.0, 792.0)); + assert!(near(p.1[0].0, Rect::new(0.0, 0.0, 50.0, 100.0)), "{:?}", p.1); + let mut t = s; + t["transverse"] = json!(true); + assert_eq!(spooled(t).0, (612.0, 792.0)); +} + +#[test] +fn for_a_printer_auto_rotated_artboards_all_print_upright() { + // A wide artboard and a tall one, the paper turned to each. + let mut d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 10.0, 10.0), rgb(1.0, 0.0, 0.0))]); + let mut wide = artboard(1, 0.0); + wide.rect = Rect::new(0.0, 0.0, 300.0, 100.0); + let mut tall = artboard(2, 400.0); + tall.rect = Rect::new(400.0, 0.0, 500.0, 300.0); + d.artboards = vec![wide, tall]; + let set = settings(json!({"autoRotate": true})); + let saved = print(&d, &PrintOptions { settings: set.clone(), ..Default::default() }).unwrap(); + let sizes = |bytes: &[u8]| pages(bytes).into_iter().map(|p| p.0).collect::>(); + assert_eq!(sizes(&saved.bytes), [(792.0, 612.0), (612.0, 792.0)]); + let spooled = print(&d, &PrintOptions { settings: set, for_printer: true, ..Default::default() }).unwrap(); + assert_eq!(sizes(&spooled.bytes), [(612.0, 792.0), (612.0, 792.0)]); +} + #[test] fn golden_page_one() { // 300 × 200: red left half, blue right half, a black frame; on Letter, turned landscape, diff --git a/crates/ui-egui/src/dialogs/tests_print.rs b/crates/ui-egui/src/dialogs/tests_print.rs index 490cbf677..bf213c789 100644 --- a/crates/ui-egui/src/dialogs/tests_print.rs +++ b/crates/ui-egui/src/dialogs/tests_print.rs @@ -148,6 +148,40 @@ fn done_keeps_the_settings_without_printing_and_bad_settings_keep_the_dialog() { assert!(!crate::menus::enabled(&app, "print.printerSetup"), "the mock has no settings to open"); } +/// A print queue that puts each page on the paper as it is, as `lp` does. +struct UprightQueue(Jobs); + +impl PrintService for UprightQueue { + fn printers(&mut self) -> Vec { + vec![] + } + + fn print(&mut self, job: &PrintJob) -> Result { + MockPrint(self.0.clone()).print(job) + } + + fn turns_landscape(&self) -> bool { + false + } +} + +#[test] +fn a_landscape_job_goes_upright_to_a_queue_that_does_not_turn_it() { + let sizes = |pdf: &[u8]| vectorcraft_pdf::info(pdf, None).unwrap().pages.iter().map(|p| (p.width, p.height)).collect::>(); + let landscape = json!({"settings": {"autoRotate": false, "orientation": "landscape", "artboards": "range", "range": "1"}}); + // The desktop's queue: Letter upright, the art turned on it. + let jobs = Jobs::default(); + let services = Services { print: Some(Box::new(UprightQueue(jobs.clone()))), ..Default::default() }; + let mut desktop = VectorcraftApp::new(Session::new(), services); + desktop.run("file.new", json!({"width": 120, "height": 90})).unwrap(); + desktop.run("file.print", landscape.clone()).unwrap(); + assert_eq!(sizes(&jobs.borrow()[0].2), [(612.0, 792.0)]); + // A service that turns pages itself (the browser's dialog) gets the landscape page. + let (mut app, jobs) = app(); + app.run("file.print", landscape).unwrap(); + assert_eq!(sizes(&jobs.borrow()[0].2), [(792.0, 612.0)]); +} + #[test] fn without_a_print_service_print_saves_a_pdf() { let written: Written = Rc::default(); diff --git a/crates/ui-egui/src/print.rs b/crates/ui-egui/src/print.rs index 0fcb163da..1eeea2846 100644 --- a/crates/ui-egui/src/print.rs +++ b/crates/ui-egui/src/print.rs @@ -42,6 +42,12 @@ pub trait PrintService { fn shared_print(&self) -> Option { None } + /// Whether the system turns a page wider than tall to fit the paper. The desktop's print + /// queues put each page on the paper as it is, so their jobs are written upright + /// (`file.print`'s `forPrinter`); the browser's print dialog turns them itself. + fn turns_landscape(&self) -> bool { + true + } /// Whether Setup… can open the system's printer settings. fn has_setup(&self) -> bool { false @@ -91,6 +97,9 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result { v["printed"] = json!(false); return Ok(v); } + if app.services.print.as_ref().is_some_and(|s| !s.turns_landscape()) { + q["forPrinter"] = json!(true); + } let mut v = app.session.execute("file.print", &q).map_err(|e| e.to_string())?; let data = v.as_object_mut().and_then(|o| o.remove("dataBase64")); let pdf = data.as_ref().and_then(Value::as_str).and_then(vectorcraft_format::base64_decode).ok_or("the print job has no data")?;