//! Printing through a Windows file printer and fake Linux portal. #[cfg(not(target_os = "macos"))] use std::path::Path; /// Pages of `sizes` points, each with an inch square. #[cfg(not(target_os = "macos"))] fn fixture(path: &Path, sizes: &[(f32, f32)]) { use lopdf::{Document, Object, Stream, dictionary}; let mut document = Document::with_version("1.7"); let tree = document.new_object_id(); let kids: Vec = sizes .iter() .map(|&(width, height)| { let square = format!("0 g 72 {} 72 72 re f", height - 144.0); let content = document.add_object(Stream::new(dictionary! {}, square.into_bytes())); let media: Vec = vec![0.into(), 0.into(), width.into(), height.into()]; document .add_object(dictionary! { "Type" => "Page", "Parent" => tree, "MediaBox" => media, "Contents" => content }) .into() }) .collect(); let count = kids.len() as i64; document.objects.insert(tree, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => count }.into()); let catalog = document.add_object(dictionary! { "Type" => "Catalog", "Pages" => tree }); document.trailer.set("Root", catalog); document.save(path).expect("the fixture saves"); } #[cfg(windows)] fn main() { let folder = tempfile::tempdir().expect("a folder"); unsafe { windows::Win32::System::WinRT::RoInitialize(windows::Win32::System::WinRT::RO_INIT_MULTITHREADED) }.expect("the runtime"); // A5, then wide A5: the paper stays upright. let mixed = printed(folder.path(), "mixed", &[(420.0, 595.0), (595.0, 420.0)]); assert_eq!(papers(&mixed), [(420, 595), (420, 595)]); let square = |page| windows_page::dark(&mixed, page, (108, 108)); assert_eq!((square(0), square(1)), (true, false), "the upright page keeps its corner"); // Turned left, its top left lands bottom left. assert!(windows_page::dark(&mixed, 1, (108, 487)), "the turned page's square"); assert!(!windows_page::dark(&mixed, 0, (312, 108)), "nothing at the upright page's other corner"); // A4 landscape alone turns the paper. let wide = printed(folder.path(), "wide", &[(842.0, 595.0)]); assert_eq!(papers(&wide), [(842, 595)]); assert!(windows_page::dark(&wide, 0, (108, 108)), "the wide page keeps its corner"); // A paper the printer lacks: refused, nothing cut or written. let source = folder.path().join("custom.pdf"); fixture(&source, &[(1000.0, 600.0)]); let job = folder.path().join("custom-job.pdf"); unsafe { std::env::set_var("ELY_PRINT_TO", &job) }; let error = futures::executor::block_on(ely_platform::print::print_pdf(&source)).expect_err("no such paper"); assert!(error.to_string().contains("page 1 is 353 × 212 mm; the printer's paper is"), "{error}"); assert!(!job.exists(), "nothing printed"); // 1.6 mm past A4: refused, not cut. fixture(&source, &[(600.0, 842.0)]); let error = futures::executor::block_on(ely_platform::print::print_pdf(&source)).expect_err("past A4"); assert!(error.to_string().contains("page 1 is 212 × 297 mm; the printer's paper is"), "{error}"); assert!(!job.exists(), "nothing printed"); println!("print: windows ok"); } /// Prints `sizes` through the file printer; the job's PDF. #[cfg(windows)] fn printed(folder: &Path, name: &str, sizes: &[(f32, f32)]) -> std::path::PathBuf { let source = folder.join(format!("{name}.pdf")); fixture(&source, sizes); let job = folder.join(format!("{name}-job.pdf")); // Before any thread starts: the job goes to a file. unsafe { std::env::set_var("ELY_PRINT_TO", &job) }; assert!(futures::executor::block_on(ely_platform::print::print_pdf(&source)).expect("prints"), "printed"); job } /// Each printed page's paper in whole points. #[cfg(windows)] fn papers(job: &Path) -> Vec<(i64, i64)> { let document = lopdf::Document::load(job).expect("the job is a PDF"); document .get_pages() .into_values() .map(|page| { let media = document.get_dictionary(page).and_then(|page| page.get(b"MediaBox")).expect("a media box"); let corners: Vec = media.as_array().expect("an array").iter().map(|n| n.as_float().expect("a number")).collect(); ((corners[2] - corners[0]).round() as i64, (corners[3] - corners[1]).round() as i64) }) .collect() } /// Reads printed pages back through Windows' own renderer. #[cfg(windows)] mod windows_page { use std::path::Path; use windows::{ Data::Pdf::{PdfDocument, PdfPageRenderOptions}, Storage::{ StorageFile, Streams::{DataReader, InMemoryRandomAccessStream}, }, core::HSTRING, }; /// Whether `(x, y)` points from the top left is dark. pub fn dark(job: &Path, page: u32, (x, y): (u32, u32)) -> bool { let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(job)).and_then(|file| file.join()).expect("the job opens"); let document = PdfDocument::LoadFromFileAsync(&file).and_then(|document| document.join()).expect("the job reads"); let page = document.GetPage(page).expect("the page"); let size = page.Size().expect("its size"); let options = PdfPageRenderOptions::new().expect("options"); // Asked in DIPs; scaled back to one pixel per point. options.SetDestinationWidth((size.Width * 0.75).round() as u32).expect("a width"); let stream = InMemoryRandomAccessStream::new().expect("a stream"); page.RenderWithOptionsToStreamAsync(&stream, &options).and_then(|render| render.join()).expect("renders"); let length = stream.Size().expect("a size") as u32; let reader = DataReader::CreateDataReader(&stream.GetInputStreamAt(0).expect("a start")).expect("a reader"); reader.LoadAsync(length).and_then(|load| load.join()).expect("loads"); let mut png = vec![0; length as usize]; reader.ReadBytes(&mut png).expect("reads"); let image = image::load_from_memory(&png).expect("a PNG").to_luma8(); let scale = f64::from(image.width()) / f64::from(size.Width * 0.75); let at = |point: u32| (f64::from(point) * scale) as u32; image.get_pixel(at(x), at(y)).0[0] < 64 } } #[cfg(target_os = "linux")] fn main() { use std::io::BufRead as _; // A private session bus: the stand-in portal and nothing else. let bus = std::process::Command::new("dbus-daemon") .args(["--session", "--nofork", "--nopidfile", "--print-address=1"]) .stdout(std::process::Stdio::piped()) .spawn(); let Ok(mut bus) = bus else { println!("SKIP: no dbus-daemon"); return; }; let mut address = String::new(); std::io::BufReader::new(bus.stdout.take().expect("its address")).read_line(&mut address).expect("reads the address"); // Before any thread starts: the portal client finds this bus. unsafe { std::env::set_var("DBUS_SESSION_BUS_ADDRESS", address.trim()) }; let folder = tempfile::tempdir().expect("a folder"); let wide = folder.path().join("wide.pdf"); fixture(&wide, &[(842.0, 595.0)]); let three = folder.path().join("three.pdf"); fixture(&three, &[(595.0, 842.0), (420.0, 595.0), (300.0, 300.0)]); futures::executor::block_on(portal::check(&wide, &three)); bus.kill().expect("the bus stops"); println!("print: linux ok"); } /// A stand-in for org.freedesktop.portal.Print. #[cfg(target_os = "linux")] mod portal { use std::{ collections::HashMap, io::Read as _, path::Path, sync::{Arc, Mutex}, }; use zbus::{ Connection, message::Header, zvariant::{OwnedFd, OwnedObjectPath, OwnedValue, Value}, }; #[derive(Default)] struct Seen { cancel: bool, /// What the dialog answers the user chose. choices: Vec<(&'static str, &'static str)>, setup: HashMap, formats: Option>, token: Option, bytes: Option>, } struct Portal(Arc>); /// The request object the caller listens on. fn handle(header: &Header<'_>, options: &HashMap) -> OwnedObjectPath { let sender = header.sender().expect("a sender").trim_start_matches(':').replace('.', "_"); let token: &str = options.get("handle_token").expect("a handle token").downcast_ref().expect("a string"); OwnedObjectPath::try_from(format!("/org/freedesktop/portal/desktop/request/{sender}/{token}")).expect("a path") } async fn answer(connection: &Connection, handle: &OwnedObjectPath, code: u32, results: HashMap<&str, Value<'_>>) { connection .emit_signal(None::<()>, handle, "org.freedesktop.portal.Request", "Response", &(code, results)) .await .expect("the response goes out"); } #[zbus::interface(name = "org.freedesktop.portal.Print")] impl Portal { // The portal's own signature. #[allow(clippy::too_many_arguments)] async fn prepare_print( &self, #[zbus(header)] header: Header<'_>, #[zbus(connection)] connection: &Connection, _parent: &str, _title: &str, _settings: HashMap, page_setup: HashMap, options: HashMap, ) -> OwnedObjectPath { let handle = handle(&header, &options); let formats = options.get("supported_output_file_formats").map(|formats| formats.try_clone().expect("a copy").try_into().expect("strings")); let (cancel, choices) = { let mut seen = self.0.lock().expect("the record"); (seen.setup, seen.formats) = (page_setup.clone(), formats); (seen.cancel, seen.choices.clone()) }; // GTK hands every setting back as text. let settings: HashMap = choices.into_iter().map(|(key, value)| (key.to_string(), Value::from(value))).collect(); let results = match cancel { true => HashMap::new(), false => HashMap::from([ ("settings", Value::from(settings)), ("page-setup", Value::from(page_setup)), ("token", Value::from(7u32)), ]), }; answer(connection, &handle, u32::from(cancel), results).await; handle } async fn print( &self, #[zbus(header)] header: Header<'_>, #[zbus(connection)] connection: &Connection, _parent: &str, _title: &str, fd: OwnedFd, options: HashMap, ) -> OwnedObjectPath { let handle = handle(&header, &options); let mut bytes = Vec::new(); std::fs::File::from(std::os::fd::OwnedFd::from(fd)).read_to_end(&mut bytes).expect("the job reads"); let token = options.get("token").map(|token| token.downcast_ref::().expect("a number")); { let mut seen = self.0.lock().expect("the record"); (seen.token, seen.bytes) = (token, Some(bytes)); } answer(connection, &handle, 0, HashMap::new()).await; handle } #[zbus(property, name = "version")] fn version(&self) -> u32 { 3 } } /// A sheet's size, then each form's width and placed corners. type Sheet = ((i64, i64), Vec<(i64, [i64; 4])>); fn sheets(bytes: &[u8]) -> Vec { use lopdf::{Document, Object, content::Content}; let document = Document::load_mem(bytes).expect("a PDF"); let numbers = |object: &Object| -> Vec { object.as_array().expect("numbers").iter().map(|n| f64::from(n.as_float().expect("a number"))).collect() }; let round = |n: f64| n.round() as i64; let pages = document.get_pages().into_values().map(|page| { let dict = document.get_dictionary(page).expect("a page"); let media = numbers(dict.get(b"MediaBox").expect("a box")); let resources = dict.get(b"Resources").and_then(Object::as_dict).expect("resources"); let forms = resources.get(b"XObject").and_then(Object::as_dict).expect("forms"); let content = Content::decode(&document.get_page_content(page)).expect("content"); let mut m = vec![1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; let mut placed = Vec::new(); for op in content.operations { match op.operator.as_str() { "cm" => m = op.operands.iter().map(|n| f64::from(n.as_float().expect("a number"))).collect(), "Do" => { let form = forms.get(op.operands[0].as_name().expect("a name")).and_then(Object::as_reference).expect("a form"); let stream = document.get_object(form).and_then(Object::as_stream).expect("a stream"); let b = numbers(stream.dict.get(b"BBox").expect("a box")); let at = |x: f64, y: f64| (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]); let ((x0, y0), (x1, y1)) = (at(b[0], b[1]), at(b[2], b[3])); placed.push((round(b[2] - b[0]), [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)].map(round))); } _ => {} } } ((round(media[2]), round(media[3])), placed) }); pages.collect() } pub async fn check(source: &Path, three: &Path) { let seen = Arc::new(Mutex::new(Seen::default())); let _portal = zbus::connection::Builder::session() .and_then(|builder| builder.name("org.freedesktop.portal.Desktop")) .and_then(|builder| builder.serve_at("/org/freedesktop/portal/desktop", Portal(seen.clone()))) .expect("a portal") .build() .await .expect("the portal serves"); assert!(ely_platform::print::print_pdf(source).await.expect("prints"), "printed"); { let seen = seen.lock().expect("the record"); let text = |key: &str| seen.setup.get(key).map(|value| value.downcast_ref::().expect("text")); let number = |key: &str| seen.setup.get(key).map(|value| value.downcast_ref::().expect("a number").round()); assert_eq!(text("Name").as_deref(), Some("iso_a4")); assert_eq!(text("Orientation").as_deref(), Some("landscape")); assert_eq!((number("Width"), number("Height"), number("MarginTop")), (Some(210.0), Some(297.0), Some(0.0))); assert_eq!(seen.token, Some(7), "the print carries the dialog's token"); assert_eq!(seen.formats.as_deref(), Some(["pdf".to_string()].as_slice()), "PDF is all we hand over"); let printed = sheets(seen.bytes.as_deref().expect("a job")); assert_eq!(printed, [((842, 595), vec![(842, [0, 0, 842, 595])])], "the page fills its sheet"); } // Page two at half size, from the sheet's top left. let choices = vec![("print-pages", "ranges"), ("page-ranges", "1"), ("scale", "50")]; *seen.lock().expect("the record") = Seen { choices, ..Seen::default() }; assert!(ely_platform::print::print_pdf(three).await.expect("prints"), "printed"); let printed = sheets(seen.lock().expect("the record").bytes.as_deref().expect("a job")); assert_eq!(printed, [((595, 842), vec![(420, [0, 545, 210, 842])])]); // Two copies, uncollated, of the last two pages, in reverse. let choices = vec![("print-pages", "ranges"), ("page-ranges", "1--1"), ("reverse", "true"), ("n-copies", "2"), ("collate", "false")]; *seen.lock().expect("the record") = Seen { choices, ..Seen::default() }; assert!(ely_platform::print::print_pdf(three).await.expect("prints"), "printed"); let printed = sheets(seen.lock().expect("the record").bytes.as_deref().expect("a job")); let widths: Vec = printed.iter().map(|(_, placed)| placed[0].0).collect(); assert_eq!(widths, [300, 300, 420, 420]); // Cancelled, nothing prints. *seen.lock().expect("the record") = Seen { cancel: true, ..Seen::default() }; assert!(!ely_platform::print::print_pdf(source).await.expect("cancels"), "not printed"); assert_eq!(seen.lock().expect("the record").bytes, None, "no job went out"); } } /// macOS prints through tests/scripts/print.sh, in a real window. #[cfg(target_os = "macos")] fn main() {}