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) <noreply@anthropic.com>
This commit is contained in:
Chris Main
2026-10-24 17:29:32 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent fea8c084e8
commit 41430cc171
7 changed files with 126 additions and 4 deletions
+4
View File
@@ -23,6 +23,10 @@ impl PrintService for SystemPrint {
fn shared_print(&self) -> Option<vectorcraft_ui_egui::print::SharedPrintFn> {
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
}
+4 -2
View File
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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 }));
}
+12
View File
@@ -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::<String>::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::<Vec<_>>();
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);
}
+23 -2
View File
@@ -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<PrintPlan<'a>,
}
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<PrintPlan<'a>,
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<PrintReport, PdfError> {
craft_pdf::write::guard(|| leaving_out_undecodable(doc, |left_out| print_inner(doc, opts, left_out)))
+40
View File
@@ -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::<Vec<_>>();
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,
+34
View File
@@ -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<Printer> {
vec![]
}
fn print(&mut self, job: &PrintJob) -> Result<String, String> {
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::<Vec<_>>();
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();
+9
View File
@@ -42,6 +42,12 @@ pub trait PrintService {
fn shared_print(&self) -> Option<SharedPrintFn> {
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<Value, String> {
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")?;