mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
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>
227 lines
12 KiB
Rust
227 lines
12 KiB
Rust
//! File → Print: `print.setup` saves the settings with the document (one undo step, kept in the
|
||
//! native file), `print.preview` lists what would print, `file.print` writes the PDF.
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
use super::*;
|
||
|
||
/// A 300 × 200 RGB document with a rectangle; and the rectangle's id.
|
||
fn session() -> (Session, u64) {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||
(s, id)
|
||
}
|
||
|
||
/// `file.print {p}` → (PDF bytes, its answer).
|
||
fn print(s: &mut Session, p: Value) -> (Vec<u8>, Value) {
|
||
let v = s.execute("file.print", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||
(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(), v)
|
||
}
|
||
|
||
#[test]
|
||
fn setup_is_saved_with_the_document_as_one_undo_step() {
|
||
let (mut s, _) = session();
|
||
assert_eq!(s.execute("print.setup", &json!({})).unwrap()["settings"]["copies"], 1, "defaults before any setup");
|
||
assert!(s.doc().unwrap().doc.print_setup.is_none(), "reading the settings changes nothing");
|
||
let v = s.execute("print.setup", &json!({"settings": {"copies": 3, "media": "a4", "marks": {"trim": true}}})).unwrap();
|
||
assert_eq!((v["settings"]["copies"].clone(), v["settings"]["media"].clone()), (json!(3), json!("a4")));
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
// Further options go over the saved ones; the same settings again are no new step.
|
||
let v = s.execute("print.setup", &json!({"settings": {"collate": false}})).unwrap();
|
||
let got = (v["settings"]["copies"].clone(), v["settings"]["collate"].clone(), v["settings"]["marks"]["trim"].clone());
|
||
assert_eq!(got, (json!(3), json!(false), json!(true)));
|
||
s.execute("print.setup", &json!({"settings": {"collate": false}})).unwrap();
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||
// Saved in the native file, and gone again after undoing both steps.
|
||
let doc = &s.doc().unwrap().doc;
|
||
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||
assert_eq!(back.print_setup, doc.print_setup);
|
||
assert!(back.print_setup.as_ref().is_some_and(|p| p["copies"] == 3));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!(s.doc().unwrap().doc.print_setup.is_none());
|
||
// Files from before print settings load without them.
|
||
let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||
v.as_object_mut().unwrap().remove("print_setup");
|
||
assert!(vectorcraft_format::load(v.to_string().as_bytes()).unwrap().print_setup.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn print_writes_the_saved_setup_with_overrides() {
|
||
let (mut s, _) = session();
|
||
s.execute("print.setup", &json!({"settings": {"media": "a4", "scaling": "fit"}})).unwrap();
|
||
let (bytes, v) = print(&mut s, json!({"settings": {"copies": 2}}));
|
||
assert_eq!(v["pages"], 2);
|
||
let info = vectorcraft_pdf::info(&bytes, None).unwrap();
|
||
assert_eq!(info.pages.len(), 2);
|
||
// A4 turned landscape for the wide artboard.
|
||
assert_eq!((info.pages[0].width.round(), info.pages[0].height.round()), (842.0, 595.0));
|
||
assert_eq!(s.execute("print.setup", &json!({})).unwrap()["settings"]["copies"], 1, "printing doesn't change the saved setup");
|
||
// To a file.
|
||
let path = std::env::temp_dir().join(format!("vc-print-{}.pdf", std::process::id()));
|
||
let v = s.execute("file.print", &json!({"path": path.to_string_lossy()})).unwrap();
|
||
assert_eq!(v["pages"], 1);
|
||
assert_eq!(std::fs::read(&path).unwrap().len() as u64, v["bytes"].as_u64().unwrap());
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
/// A document with four 300 × 200 artboards, set up to print two copies, and a saved print preset
|
||
/// "Sheet" that prints artboard 4 only.
|
||
fn four_artboards_and_a_preset() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 4})).unwrap();
|
||
s.execute("print.setup", &json!({"settings": {"copies": 2}})).unwrap();
|
||
s.execute("print.presets.save", &json!({"name": "Sheet", "settings": {"artboards": "range", "range": "4"}})).unwrap();
|
||
s
|
||
}
|
||
|
||
#[test]
|
||
fn print_takes_a_print_preset_by_name() {
|
||
let mut s = four_artboards_and_a_preset();
|
||
// The preset's settings stand in for the document's: one copy of artboard 4.
|
||
let (bytes, v) = print(&mut s, json!({"preset": "sheet"}));
|
||
assert_eq!((v["pages"].clone(), vectorcraft_pdf::info(&bytes, None).unwrap().pages.len()), (json!(1), 1));
|
||
let (_, v) = print(&mut s, json!({"preset": "Sheet", "settings": {"copies": 3}}));
|
||
assert_eq!(v["pages"], 3, "settings apply over the preset's");
|
||
let (_, v) = print(&mut s, json!({"preset": "[Default]"}));
|
||
assert_eq!(v["pages"], 4, "[Default]: every artboard, one copy");
|
||
assert_eq!(s.execute("print.setup", &json!({})).unwrap()["settings"]["copies"], 2, "printing doesn't change the saved setup");
|
||
// A PostScript file too.
|
||
let v = s.execute("file.print", &json!({"preset": "Sheet", "format": "postscript"})).unwrap();
|
||
assert_eq!(v["pages"], 1);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unknown_print_preset_is_refused_with_the_presets() {
|
||
let mut s = four_artboards_and_a_preset();
|
||
for c in ["file.print", "print.preview", "print.setup"] {
|
||
// Agents (MCP, the control channel) may pass `preset`.
|
||
s.check_params(c, &json!({"preset": "Sheet", "settings": {}})).unwrap();
|
||
let e = s.execute(c, &json!({"preset": "Nope"})).unwrap_err();
|
||
assert!(matches!(e, EngineError::BadParams { .. }), "{c}: {e}");
|
||
assert!(e.to_string().contains("no print preset named `Nope` (presets: [Default], Sheet)"), "{c}: {e}");
|
||
assert!(matches!(s.execute(c, &json!({"preset": 4})), Err(EngineError::BadParams { .. })), "{c}");
|
||
}
|
||
assert_eq!(s.doc().unwrap().doc.print_setup.as_ref().unwrap()["copies"], 2);
|
||
}
|
||
|
||
#[test]
|
||
fn setup_with_a_preset_stores_its_settings() {
|
||
let mut s = four_artboards_and_a_preset();
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
let v = s.execute("print.setup", &json!({"preset": "Sheet"})).unwrap();
|
||
let got = (v["settings"]["copies"].clone(), v["settings"]["artboards"].clone(), v["settings"]["range"].clone());
|
||
assert_eq!(got, (json!(1), json!("range"), json!("4")));
|
||
let stored = s.doc().unwrap().doc.print_setup.clone().unwrap();
|
||
assert_eq!(stored, v["settings"]);
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||
// The same preset again is no new step; settings apply over it.
|
||
s.execute("print.setup", &json!({"preset": "Sheet"})).unwrap();
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||
let v = s.execute("print.setup", &json!({"preset": "Sheet", "settings": {"collate": false}})).unwrap();
|
||
assert_eq!((v["settings"]["range"].clone(), v["settings"]["collate"].clone()), (json!("4"), json!(false)));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.print_setup.as_ref().unwrap()["copies"], 2);
|
||
}
|
||
|
||
#[test]
|
||
fn preview_lists_pages_tiles_inks_and_warnings() {
|
||
let (mut s, _) = session();
|
||
let v = s.execute("print.preview", &json!({"settings": {"output": {"mode": "separations"}, "copies": 2}})).unwrap();
|
||
assert_eq!(v["pages"], 8);
|
||
assert_eq!(
|
||
v["sheets"].as_array().unwrap().iter().map(|p| p["ink"].as_str().unwrap()).collect::<Vec<_>>(),
|
||
["Cyan", "Magenta", "Yellow", "Black"]
|
||
);
|
||
assert!(v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("RGB")), "{}", v["warnings"]);
|
||
assert_eq!(v["settings"]["copies"], 2);
|
||
let v = s.execute("print.preview", &json!({"settings": {"scaling": "tileFull", "scale": {"width": 400, "height": 400}}})).unwrap();
|
||
let tiles = &v["tiles"][0];
|
||
assert_eq!((tiles["columns"].clone(), tiles["rows"].clone()), (json!(2), json!(2)), "{v}");
|
||
assert_eq!(v["pages"], 4);
|
||
assert_eq!(v["sheets"][3]["tile"], 4);
|
||
}
|
||
|
||
#[test]
|
||
fn bad_settings_are_bad_params() {
|
||
let (mut s, _) = session();
|
||
for p in [
|
||
json!({"settings": "letter"}),
|
||
json!({"settings": {"copies": 0}}),
|
||
json!({"settings": {"copies": -1}}),
|
||
json!({"settings": {"media": "quarto"}}),
|
||
json!({"settings": {"artboards": "range", "range": "2"}}),
|
||
json!({"settings": {"scaling": "tileFull", "overlap": 500}}),
|
||
] {
|
||
for c in ["file.print", "print.preview"] {
|
||
assert!(matches!(s.execute(c, &p), Err(EngineError::BadParams { .. })), "{c} {p}");
|
||
}
|
||
}
|
||
// The setup refuses what it can check without laying the document out.
|
||
assert!(matches!(s.execute("print.setup", &json!({"settings": {"copies": 1000}})), Err(EngineError::BadParams { .. })));
|
||
assert!(s.doc().unwrap().doc.print_setup.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn raster_effects_print_as_images() {
|
||
let (mut s, id) = session();
|
||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap();
|
||
let (bytes, _) = print(&mut s, json!({}));
|
||
assert!(String::from_utf8_lossy(&bytes).contains("/Image"));
|
||
}
|
||
|
||
#[test]
|
||
fn type_in_a_missing_family_warns_as_pdf_export_does() {
|
||
let (mut s, _) = session();
|
||
let id = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap()["id"].clone();
|
||
let fallback = |v: &Value| -> Vec<String> {
|
||
v["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("fallback font")).map(str::to_string).collect()
|
||
};
|
||
let export = fallback(&s.execute("document.exportPdf", &json!({})).unwrap());
|
||
assert!(export.first().is_some_and(|w| w.contains("Nonexistent Sans Probe")), "{export:?}");
|
||
let (_, v) = print(&mut s, json!({}));
|
||
assert_eq!(fallback(&v), export);
|
||
// Type printed as an image (a raster effect, Print as Bitmap) is drawn in the fallback font too.
|
||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap();
|
||
for settings in [json!({}), json!({"advanced": {"printAsBitmap": true}})] {
|
||
let (_, v) = print(&mut s, json!({ "settings": settings }));
|
||
assert_eq!(fallback(&v), export, "{settings}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn type_that_does_not_fit_its_frame_is_named() {
|
||
let (mut s, _) = session();
|
||
let p = json!({"x": 10, "y": 100, "text": "Ab\nCd\nEf\nGh", "size": 12, "area": {"width": 100, "height": 20}});
|
||
let id = s.execute("text.create", &p).unwrap()["id"].clone();
|
||
let over = |v: &Value| -> Vec<String> {
|
||
v["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("fit its frame")).map(str::to_string).collect()
|
||
};
|
||
let (_, v) = print(&mut s, json!({}));
|
||
assert_eq!(over(&v), [format!("1 text object doesn't fit its frame: the text past the end of the frame doesn't print (id {id})")]);
|
||
assert_eq!(over(&v), over(&s.execute("print.preview", &json!({})).unwrap()));
|
||
// Type printed as an image is named too.
|
||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap();
|
||
for settings in [json!({}), json!({"advanced": {"printAsBitmap": true}})] {
|
||
let (_, v) = print(&mut s, json!({ "settings": settings }));
|
||
assert_eq!(over(&v).len(), 1, "{settings}");
|
||
}
|
||
s.execute("text.areaOptions", &json!({"ids": [id], "height": 150})).unwrap();
|
||
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);
|
||
}
|