Files
ElyOffice/crates/ely-platform/tests/print.rs
T
ZacharyZhang-NYandClaude Opus 5.5 13546ea899 R7.6: Windows and Linux print (#3)
Windows prints through the system dialog at the document's paper;
Linux through the desktop portal, the dialog's choices laid out by Ely.
Round 3 findings fixed without a further review, as the rules say.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 16:59:46 -04:00

340 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Object> = 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<Object> = 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<f32> = 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<String, OwnedValue>,
formats: Option<Vec<String>>,
token: Option<u32>,
bytes: Option<Vec<u8>>,
}
struct Portal(Arc<Mutex<Seen>>);
/// The request object the caller listens on.
fn handle(header: &Header<'_>, options: &HashMap<String, OwnedValue>) -> 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<String, OwnedValue>,
page_setup: HashMap<String, OwnedValue>,
options: HashMap<String, OwnedValue>,
) -> 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<String, Value> = 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<String, OwnedValue>,
) -> 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::<u32>().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<Sheet> {
use lopdf::{Document, Object, content::Content};
let document = Document::load_mem(bytes).expect("a PDF");
let numbers = |object: &Object| -> Vec<f64> { 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::<String>().expect("text"));
let number = |key: &str| seen.setup.get(key).map(|value| value.downcast_ref::<f64>().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<i64> = 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() {}