From f256775bec5b753577cf31be1b85de81f02e7561 Mon Sep 17 00:00:00 2001 From: cart2notasmn-ops Date: Tue, 27 Oct 2026 12:31:35 -0300 Subject: [PATCH 1/7] fix: create the Direct2D factory multi-threaded for Windows.Data.Pdf Windows.Data.Pdf renders a page on several worker threads that share the device context passed to RenderPageToDeviceContext. With a single-threaded factory Direct2D takes no locks, so those threads race inside d2d1.dll: access violations reading near null (d2d1.dll+0x93fd4, address 0x338) or executing freed memory (BEX64), mostly on long jobs of scanned pages. Co-Authored-By: Claude Opus 5.5 --- src/printer/dxgi.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/printer/dxgi.rs b/src/printer/dxgi.rs index 588d721..01e9d4d 100644 --- a/src/printer/dxgi.rs +++ b/src/printer/dxgi.rs @@ -20,7 +20,7 @@ use windows::Win32::Graphics::Direct2D::ID2D1Factory1; use windows::Win32::Graphics::Direct2D::ID2D1PrintControl; use windows::Win32::Graphics::Direct2D::D2D1_DEVICE_CONTEXT_OPTIONS_NONE; use windows::Win32::Graphics::Direct2D::D2D1_FACTORY_OPTIONS; -use windows::Win32::Graphics::Direct2D::D2D1_FACTORY_TYPE_SINGLE_THREADED; +use windows::Win32::Graphics::Direct2D::D2D1_FACTORY_TYPE_MULTI_THREADED; use windows::Win32::Graphics::Direct2D::D2D1_PRINT_CONTROL_PROPERTIES; use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE; use windows::Win32::Graphics::Direct3D11::D3D11CreateDevice; @@ -151,8 +151,11 @@ impl DxgiPrintContext { .map_err(DxgiPrintContextError::InitializeDirectX)?; let d2d_factory_options = D2D1_FACTORY_OPTIONS::default(); + // Windows.Data.Pdf renders a page on several worker threads that share this + // device context. A single-threaded factory has no locking, so they race inside + // d2d1.dll (access violations reading near null or calling freed memory). let d2d_factory: ID2D1Factory1 = D2D1CreateFactory( - D2D1_FACTORY_TYPE_SINGLE_THREADED, + D2D1_FACTORY_TYPE_MULTI_THREADED, Some(&d2d_factory_options), ) .map_err(DxgiPrintContextError::InitializeDirectX)?; From 17f5f1e3c5038be6bd122df36fc5b5aa2672502c Mon Sep 17 00:00:00 2001 From: Zhihui Song Date: Sat, 7 Nov 2026 14:12:05 +0800 Subject: [PATCH 2/7] fix: load Windows PDF print jobs through a stream --- src/printer/win_pdf.rs | 75 +++++++++++++++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/src/printer/win_pdf.rs b/src/printer/win_pdf.rs index aa47e45..23d5ffc 100644 --- a/src/printer/win_pdf.rs +++ b/src/printer/win_pdf.rs @@ -3,11 +3,11 @@ use super::DxgiPrintContextError; use crate::printer::FilePrinter; use crate::printer::PrinterDevice; use crate::ticket::PrintTicket; +use std::io::Read as _; use std::path::Path; use thiserror::Error; -use windows::core::HSTRING; use windows::Data::Pdf::PdfDocument; -use windows::Storage::StorageFile; +use windows::Storage::Streams::{DataWriter, InMemoryRandomAccessStream}; use windows::Win32::Graphics::Direct2D::Common::D2D_SIZE_F; use windows::Win32::System::WinRT::Pdf::PdfCreateRenderer; use windows::Win32::System::WinRT::Pdf::PDF_RENDER_PARAMS; @@ -70,16 +70,7 @@ impl WinPdfPrinter { let print_control = &context.print_control; let d2d_context = &context.d2d_context; unsafe { - let absolute_path = - std::path::absolute(path).map_err(WinPdfPrinterError::InvalidPath)?; - let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(absolute_path.as_path())) - .map_err(WinPdfPrinterError::FailedToOpenDocument)? - .join() - .map_err(WinPdfPrinterError::FailedToOpenDocument)?; - let pdf_document = PdfDocument::LoadFromFileAsync(&file) - .map_err(WinPdfPrinterError::FailedToOpenDocument)? - .join() - .map_err(WinPdfPrinterError::FailedToOpenDocument)?; + let pdf_document = load_document(path)?; let pdf_renderer = PdfCreateRenderer(dxgi_device).map_err(WinPdfPrinterError::RenderError)?; @@ -127,8 +118,68 @@ impl WinPdfPrinter { } } +// StorageFile path resolution can refuse otherwise readable desktop files (for example +// hidden temporary job files). Read with desktop file access and give Windows.Data.Pdf +// a stream instead. Transfer in chunks to avoid a second whole-file byte allocation. +fn load_document(path: &Path) -> Result { + let mut file = std::fs::File::open(path).map_err(WinPdfPrinterError::InvalidPath)?; + let stream = + InMemoryRandomAccessStream::new().map_err(WinPdfPrinterError::FailedToOpenDocument)?; + let writer = + DataWriter::CreateDataWriter(&stream).map_err(WinPdfPrinterError::FailedToOpenDocument)?; + let mut buffer = [0u8; 64 * 1024]; + loop { + let count = file + .read(&mut buffer) + .map_err(WinPdfPrinterError::InvalidPath)?; + if count == 0 { + break; + } + // Read guarantees count is within the supplied buffer. + writer + .WriteBytes(&buffer[..count]) + .map_err(WinPdfPrinterError::FailedToOpenDocument)?; + writer + .StoreAsync() + .map_err(WinPdfPrinterError::FailedToOpenDocument)? + .join() + .map_err(WinPdfPrinterError::FailedToOpenDocument)?; + } + writer + .DetachStream() + .map_err(WinPdfPrinterError::FailedToOpenDocument)?; + stream + .Seek(0) + .map_err(WinPdfPrinterError::FailedToOpenDocument)?; + PdfDocument::LoadFromStreamAsync(&stream) + .map_err(WinPdfPrinterError::FailedToOpenDocument)? + .join() + .map_err(WinPdfPrinterError::FailedToOpenDocument) +} + #[cfg(test)] mod tests { + #[test] + fn loads_a_hidden_job_file_without_storage_broker_path_access() { + use std::io::Write; + use std::os::windows::fs::OpenOptionsExt; + let _com = crate::utils::com::ComInitializer::new(); + let path = + std::env::temp_dir().join(format!("winprint-hidden-{}.pdf", uuid::Uuid::new_v4())); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .attributes(2) + .open(&path) + .unwrap(); // FILE_ATTRIBUTE_HIDDEN + file.write_all(include_bytes!("../../test_data/test_document.pdf")) + .unwrap(); + drop(file); + let result = super::load_document(&path); + std::fs::remove_file(&path).unwrap(); + assert!(result.unwrap().PageCount().unwrap() > 0); + } + use super::WinPdfPrinter; use crate::{printer::FilePrinter, test_utils::null_device}; use std::path::Path; From b334ebbbfe2ddfdc8b2deb2a7b372f0cf0a0d3e7 Mon Sep 17 00:00:00 2001 From: Zhihui Song Date: Sat, 7 Nov 2026 15:05:34 +0800 Subject: [PATCH 3/7] fix: read PDF jobs on demand through a read-only file stream --- src/printer/win_pdf.rs | 116 +++++++++++++++++++++++++++++------------ 1 file changed, 82 insertions(+), 34 deletions(-) diff --git a/src/printer/win_pdf.rs b/src/printer/win_pdf.rs index 23d5ffc..a7865cd 100644 --- a/src/printer/win_pdf.rs +++ b/src/printer/win_pdf.rs @@ -3,12 +3,14 @@ use super::DxgiPrintContextError; use crate::printer::FilePrinter; use crate::printer::PrinterDevice; use crate::ticket::PrintTicket; -use std::io::Read as _; use std::path::Path; use thiserror::Error; +use windows::core::HSTRING; use windows::Data::Pdf::PdfDocument; -use windows::Storage::Streams::{DataWriter, InMemoryRandomAccessStream}; +use windows::Storage::FileAccessMode; +use windows::Storage::Streams::IRandomAccessStream; use windows::Win32::Graphics::Direct2D::Common::D2D_SIZE_F; +use windows::Win32::System::WinRT::CreateRandomAccessStreamOnFile; use windows::Win32::System::WinRT::Pdf::PdfCreateRenderer; use windows::Win32::System::WinRT::Pdf::PDF_RENDER_PARAMS; @@ -118,39 +120,27 @@ impl WinPdfPrinter { } } -// StorageFile path resolution can refuse otherwise readable desktop files (for example -// hidden temporary job files). Read with desktop file access and give Windows.Data.Pdf -// a stream instead. Transfer in chunks to avoid a second whole-file byte allocation. -fn load_document(path: &Path) -> Result { - let mut file = std::fs::File::open(path).map_err(WinPdfPrinterError::InvalidPath)?; - let stream = - InMemoryRandomAccessStream::new().map_err(WinPdfPrinterError::FailedToOpenDocument)?; - let writer = - DataWriter::CreateDataWriter(&stream).map_err(WinPdfPrinterError::FailedToOpenDocument)?; - let mut buffer = [0u8; 64 * 1024]; - loop { - let count = file - .read(&mut buffer) - .map_err(WinPdfPrinterError::InvalidPath)?; - if count == 0 { - break; - } - // Read guarantees count is within the supplied buffer. - writer - .WriteBytes(&buffer[..count]) - .map_err(WinPdfPrinterError::FailedToOpenDocument)?; - writer - .StoreAsync() - .map_err(WinPdfPrinterError::FailedToOpenDocument)? - .join() - .map_err(WinPdfPrinterError::FailedToOpenDocument)?; +// StorageFile's broker can refuse otherwise readable desktop job files. Open a +// read-only, file-backed WinRT stream directly: no whole-file copy and no per-chunk +// DataWriter/StoreAsync operations. The PDF renderer reads the file on demand. +fn open_document_stream(path: &Path) -> Result { + let absolute_path = std::path::absolute(path).map_err(WinPdfPrinterError::InvalidPath)?; + let file_path = HSTRING::from(absolute_path.as_path()); + if file_path.contains(&0) { + return Err(WinPdfPrinterError::InvalidPath(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "the PDF path contains a NUL character", + ))); } - writer - .DetachStream() - .map_err(WinPdfPrinterError::FailedToOpenDocument)?; - stream - .Seek(0) - .map_err(WinPdfPrinterError::FailedToOpenDocument)?; + // SAFETY: HSTRING owns a NUL-terminated path for the duration of the call. + // The windows binding supplies IRandomAccessStream's IID and owns the returned + // COM reference. Read access does not create or modify the input file. + unsafe { CreateRandomAccessStreamOnFile(&file_path, FileAccessMode::Read.0 as u32) } + .map_err(WinPdfPrinterError::FailedToOpenDocument) +} + +fn load_document(path: &Path) -> Result { + let stream = open_document_stream(path)?; PdfDocument::LoadFromStreamAsync(&stream) .map_err(WinPdfPrinterError::FailedToOpenDocument)? .join() @@ -180,6 +170,64 @@ mod tests { assert!(result.unwrap().PageCount().unwrap() > 0); } + #[test] + fn file_stream_reads_the_end_of_a_large_file_on_demand() { + use windows::Storage::Streams::{DataReader, InputStreamOptions}; + let _com = crate::utils::com::ComInitializer::new(); + let path = + std::env::temp_dir().join(format!("winprint-large-{}.bin", uuid::Uuid::new_v4())); + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .unwrap(); + let size = 256 * 1024 * 1024; + file.set_len(size).unwrap(); + drop(file); + { + let stream = super::open_document_stream(&path).unwrap(); + assert_eq!(stream.Size().unwrap(), size); + assert!(stream.CanRead().unwrap()); + assert!(!stream.CanWrite().unwrap()); + let input = stream.GetInputStreamAt(size - 1).unwrap(); + let reader = DataReader::CreateDataReader(&input).unwrap(); + reader + .SetInputStreamOptions(InputStreamOptions::Partial) + .unwrap(); + assert_eq!(reader.LoadAsync(1).unwrap().join().unwrap(), 1); + assert_eq!(reader.ReadByte().unwrap(), 0); + } + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn a_missing_job_is_an_error_without_creating_a_file() { + let _com = crate::utils::com::ComInitializer::new(); + let path = + std::env::temp_dir().join(format!("winprint-missing-{}.pdf", uuid::Uuid::new_v4())); + assert!(super::load_document(&path).is_err()); + assert!(!path.exists()); + } + + #[test] + fn a_path_with_an_embedded_nul_is_rejected() { + let _com = crate::utils::com::ComInitializer::new(); + let path = std::path::Path::new("job.pdf\0ignored"); + assert!(matches!( + super::open_document_stream(path), + Err(super::WinPdfPrinterError::InvalidPath(_)) + )); + } + + #[test] + fn document_stream_is_read_only() { + let _com = crate::utils::com::ComInitializer::new(); + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("test_data/test_document.pdf"); + let stream = super::open_document_stream(&path).unwrap(); + assert!(stream.CanRead().unwrap()); + assert!(!stream.CanWrite().unwrap()); + } + use super::WinPdfPrinter; use crate::{printer::FilePrinter, test_utils::null_device}; use std::path::Path; From 03a4fb638734dc0f2b8e173de9e8dc895a8b1c75 Mon Sep 17 00:00:00 2001 From: Zhihui Song Date: Sun, 8 Nov 2026 00:05:46 +0800 Subject: [PATCH 4/7] fix: clarify path validation and clean up Windows stream tests safely --- Cargo.toml | 1 + src/printer/win_pdf.rs | 50 +++++++++++++++++++++++++++--------------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f9257ce..1f16f07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ features = [ "Win32_System_Ole", "Win32_System_Threading", "Win32_System_Variant", + "Win32_System_WinRT", "Win32_System_WinRT_Pdf", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", diff --git a/src/printer/win_pdf.rs b/src/printer/win_pdf.rs index a7865cd..177b85e 100644 --- a/src/printer/win_pdf.rs +++ b/src/printer/win_pdf.rs @@ -124,14 +124,9 @@ impl WinPdfPrinter { // read-only, file-backed WinRT stream directly: no whole-file copy and no per-chunk // DataWriter/StoreAsync operations. The PDF renderer reads the file on demand. fn open_document_stream(path: &Path) -> Result { + // On Windows, absolute also rejects embedded NULs before any native stream call. let absolute_path = std::path::absolute(path).map_err(WinPdfPrinterError::InvalidPath)?; let file_path = HSTRING::from(absolute_path.as_path()); - if file_path.contains(&0) { - return Err(WinPdfPrinterError::InvalidPath(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "the PDF path contains a NUL character", - ))); - } // SAFETY: HSTRING owns a NUL-terminated path for the duration of the call. // The windows binding supplies IRandomAccessStream's IID and owns the returned // COM reference. Read access does not create or modify the input file. @@ -156,18 +151,31 @@ mod tests { let _com = crate::utils::com::ComInitializer::new(); let path = std::env::temp_dir().join(format!("winprint-hidden-{}.pdf", uuid::Uuid::new_v4())); - let mut file = std::fs::OpenOptions::new() + let file = std::fs::OpenOptions::new() .write(true) .create_new(true) .attributes(2) .open(&path) .unwrap(); // FILE_ATTRIBUTE_HIDDEN - file.write_all(include_bytes!("../../test_data/test_document.pdf")) - .unwrap(); - drop(file); - let result = super::load_document(&path); - std::fs::remove_file(&path).unwrap(); - assert!(result.unwrap().PageCount().unwrap() > 0); + let path = scopeguard::guard(path, |path| { + let _ = std::fs::remove_file(path); + }); + // Handles created below the guard are released before its cleanup on a panic. + { + let mut output = file; + output + .write_all(include_bytes!("../../test_data/test_document.pdf")) + .unwrap(); + } + { + let document = super::load_document(&path).unwrap(); + assert!(document.PageCount().unwrap() > 0); + let page = document.GetPage(0).unwrap(); + let size = page.Size().unwrap(); + assert!(size.Width > 0.0 && size.Height > 0.0); + } + // Verify normal cleanup only after the page and document have been released. + std::fs::remove_file(&*path).unwrap(); } #[test] @@ -181,9 +189,14 @@ mod tests { .create_new(true) .open(&path) .unwrap(); + let path = scopeguard::guard(path, |path| { + let _ = std::fs::remove_file(path); + }); let size = 256 * 1024 * 1024; - file.set_len(size).unwrap(); - drop(file); + { + let output = file; + output.set_len(size).unwrap(); + } { let stream = super::open_document_stream(&path).unwrap(); assert_eq!(stream.Size().unwrap(), size); @@ -197,7 +210,7 @@ mod tests { assert_eq!(reader.LoadAsync(1).unwrap().join().unwrap(), 1); assert_eq!(reader.ReadByte().unwrap(), 0); } - std::fs::remove_file(path).unwrap(); + std::fs::remove_file(&*path).unwrap(); } #[test] @@ -210,12 +223,13 @@ mod tests { } #[test] - fn a_path_with_an_embedded_nul_is_rejected() { + fn windows_path_normalization_rejects_embedded_nul() { let _com = crate::utils::com::ComInitializer::new(); let path = std::path::Path::new("job.pdf\0ignored"); assert!(matches!( super::open_document_stream(path), - Err(super::WinPdfPrinterError::InvalidPath(_)) + Err(super::WinPdfPrinterError::InvalidPath(error)) + if error.kind() == std::io::ErrorKind::InvalidInput )); } From 800a6f9fd738209e04b9c8a9e0bc2c552070da8a Mon Sep 17 00:00:00 2001 From: Zhihui Song Date: Sun, 8 Nov 2026 18:41:10 +0800 Subject: [PATCH 5/7] test: bound native tail reads instead of waiting indefinitely in DataReader --- src/printer/win_pdf.rs | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/printer/win_pdf.rs b/src/printer/win_pdf.rs index 177b85e..792296b 100644 --- a/src/printer/win_pdf.rs +++ b/src/printer/win_pdf.rs @@ -180,7 +180,9 @@ mod tests { #[test] fn file_stream_reads_the_end_of_a_large_file_on_demand() { - use windows::Storage::Streams::{DataReader, InputStreamOptions}; + use std::io::{Seek, SeekFrom, Write}; + use std::time::{Duration, Instant}; + use windows::Storage::Streams::{Buffer, DataReader, InputStreamOptions}; let _com = crate::utils::com::ComInitializer::new(); let path = std::env::temp_dir().join(format!("winprint-large-{}.bin", uuid::Uuid::new_v4())); @@ -194,8 +196,11 @@ mod tests { }); let size = 256 * 1024 * 1024; { - let output = file; + let mut output = file; output.set_len(size).unwrap(); + output.seek(SeekFrom::End(-1)).unwrap(); + output.write_all(&[0xa5]).unwrap(); + output.sync_all().unwrap(); } { let stream = super::open_document_stream(&path).unwrap(); @@ -203,12 +208,24 @@ mod tests { assert!(stream.CanRead().unwrap()); assert!(!stream.CanWrite().unwrap()); let input = stream.GetInputStreamAt(size - 1).unwrap(); - let reader = DataReader::CreateDataReader(&input).unwrap(); - reader - .SetInputStreamOptions(InputStreamOptions::Partial) + let buffer = Buffer::Create(1).unwrap(); + let read = input + .ReadAsync(&buffer, 1, InputStreamOptions::None) .unwrap(); - assert_eq!(reader.LoadAsync(1).unwrap().join().unwrap(), 1); - assert_eq!(reader.ReadByte().unwrap(), 0); + let started = Instant::now(); + // AsyncStatus::Started is 0. Poll the native operation rather than waiting + // indefinitely for a completion callback: this test previously hung in CI. + while read.Status().unwrap().0 == 0 { + if started.elapsed() >= Duration::from_secs(30) { + let _ = read.Cancel(); + panic!("the one-byte file-backed read did not complete within 30 seconds"); + } + std::thread::sleep(Duration::from_millis(10)); + } + let bytes = read.GetResults().unwrap(); + assert_eq!(bytes.Length().unwrap(), 1); + let reader = DataReader::FromBuffer(&bytes).unwrap(); + assert_eq!(reader.ReadByte().unwrap(), 0xa5); } std::fs::remove_file(&*path).unwrap(); } From 51934db382f225454cf152a8657eb03789be63c4 Mon Sep 17 00:00:00 2001 From: Zhihui Song Date: Sun, 8 Nov 2026 18:53:41 +0800 Subject: [PATCH 6/7] docs: record the PDF loading timeout and lifetime limitations --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 4947c4a..c85d93d 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,11 @@ The narrow changes used by craft-print are: An unsuccessful job requests cancellation; the status connection is unadvised before its COM apartment is released. The timeout does not bound calls into a printer driver or an interactive output-file dialog. +- PDF loading uses a read-only file-backed stream, but `LoadFromStreamAsync` is + currently joined without a deadline. The completion timeout above does not bound + document loading. Adding cancellation requires keeping the stream and temporary + file alive until the native operation has actually stopped; returning from a + timeout alone is not sufficient. This remains a known limitation of the loader. - `WinPdfPrinter::print_with_progress(path, ticket, callback)` reports `(processed, total)` after each successful Direct2D AddPage, preserving one job and its ticket. These counts measure page processing, never physical printing or terminal success. From 6f3012df5df28ae6f63211a3f56d8e9410c54220 Mon Sep 17 00:00:00 2001 From: mertan-wq <235487900+mertan-wq@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:11:09 +0300 Subject: [PATCH 7/7] fix(win-pdf): render PDF pages at the job resolution Without a destination size, Windows.Data.Pdf draws each page at its size in DIPs, so whatever it rasterizes (images, Type 3 glyphs, transparency groups) reaches the sheet at about 96 dpi, whatever the driver's resolution. Read the resolution from the job's DEVMODE (ignoring a negative DMRES_* quality level in dmPrintQuality), set it on the Direct2D context, render each page at that many pixels and scale the result back to the page size by its final pixel size. This ports upstream ArcticLampyrid/winprint.rs 9819158, a4da89b and 5d49639 (released in 0.3.x) onto this fork's stream loader and multi-threaded factory. It leaves out a4da89b's scopeguard around job setup: DxgiPrintContext's Drop already cancels an unfinished job here. Co-Authored-By: ArcticLampyrid Co-Authored-By: Justin Capella <4584655+b1tninja@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 1 + src/printer/dxgi.rs | 89 ++++++++++++++++++++--------- src/printer/win_pdf.rs | 124 ++++++++++++++++++++++++++++++++++++----- 3 files changed, 176 insertions(+), 38 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1f16f07..e565dfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ thiserror = "2.0" fmt-derive = "0.1" xml = "1.2" windows-core = "0.62" +windows-numerics = "0.3" windows-implement = "0.60.2" [dependencies.windows] diff --git a/src/printer/dxgi.rs b/src/printer/dxgi.rs index 01e9d4d..a7587eb 100644 --- a/src/printer/dxgi.rs +++ b/src/printer/dxgi.rs @@ -30,6 +30,8 @@ use windows::Win32::Graphics::Direct3D11::D3D11_CREATE_DEVICE_BGRA_SUPPORT; use windows::Win32::Graphics::Direct3D11::D3D11_SDK_VERSION; use windows::Win32::Graphics::Dxgi::IDXGIDevice; use windows::Win32::Graphics::Gdi::DEVMODEW; +use windows::Win32::Graphics::Gdi::DM_PRINTQUALITY; +use windows::Win32::Graphics::Gdi::DM_YRESOLUTION; use windows::Win32::Graphics::Imaging::CLSID_WICImagingFactory2; use windows::Win32::Graphics::Imaging::D2D::IWICImagingFactory2; use windows::Win32::Storage::Xps::Printing::IPrintDocumentPackageStatusEvent; @@ -67,7 +69,23 @@ pub enum DxgiPrintContextError { FailedToClosePrintControl(#[source] windows::core::Error), } -const DEFAULT_DPI: i16 = 300; +const DEFAULT_DPI: u32 = 300; + +/// The resolution that `dev_mode` asks for, taking one axis for the other when it asks for only +/// one, or [`DEFAULT_DPI`] when it asks for none. `dmPrintQuality` can hold a negative `DMRES_*` +/// quality level instead of a resolution; that is not a resolution and is ignored. +fn resolution(dev_mode: &DEVMODEW) -> (u32, u32) { + let print_quality = unsafe { dev_mode.Anonymous1.Anonymous1.dmPrintQuality }; + let axis = |flag, dpi: i16| (dev_mode.dmFields.contains(flag) && dpi > 0).then_some(dpi as u32); + match ( + axis(DM_PRINTQUALITY, print_quality), + axis(DM_YRESOLUTION, dev_mode.dmYResolution), + ) { + (Some(x), Some(y)) => (x, y), + (Some(dpi), None) | (None, Some(dpi)) => (dpi, dpi), + (None, None) => (DEFAULT_DPI, DEFAULT_DPI), + } +} /// Represents a print context during printing via DXGI. #[allow(missing_docs)] @@ -83,6 +101,8 @@ pub struct DxgiPrintContext { pub document_target: IPrintDocumentPackageTarget, pub print_control: ID2D1PrintControl, pub completion_waiter: PrintCompletionWaiter, + /// The resolution the job's ticket asks for, in dots per inch (x, y). + pub dpi: (u32, u32), status_connection: IConnectionPoint, status_cookie: u32, completed: bool, @@ -103,29 +123,7 @@ impl DxgiPrintContext { let dev_mode = options .to_dev_mode(device) .map_err(DxgiPrintContextError::PrintTicketError)?; - let raster_dpi = { - let dev_mode = &*(dev_mode.as_ptr() as *const DEVMODEW); - let dpi_x = if (dev_mode.dmFields & windows::Win32::Graphics::Gdi::DM_PRINTQUALITY) - .0 - != 0 - { - Some(dev_mode.Anonymous1.Anonymous1.dmPrintQuality) - } else { - None - }; - let dpi_y = - if (dev_mode.dmFields & windows::Win32::Graphics::Gdi::DM_YRESOLUTION).0 != 0 { - Some(dev_mode.dmYResolution) - } else { - None - }; - match (dpi_x, dpi_y) { - (Some(x), Some(y)) => max(x, y), - (Some(x), None) => x, - (None, Some(y)) => y, - (None, None) => DEFAULT_DPI, - } - }; + let dpi = resolution(&*(dev_mode.as_ptr() as *const DEVMODEW)); let mut d3d_device: Option = None; let mut d3d_context: Option = None; D3D11CreateDevice( @@ -166,6 +164,7 @@ impl DxgiPrintContext { let d2d_context = d2d_device .CreateDeviceContext(D2D1_DEVICE_CONTEXT_OPTIONS_NONE) .map_err(DxgiPrintContextError::InitializeDirectX)?; + d2d_context.SetDpi(dpi.0 as f32, dpi.1 as f32); let wic_factory: IWICImagingFactory2 = CoCreateInstance(&CLSID_WICImagingFactory2, None, CLSCTX_ALL) @@ -185,7 +184,7 @@ impl DxgiPrintContext { ) .map_err(DxgiPrintContextError::FailedToStartJob)?; let print_control_options = D2D1_PRINT_CONTROL_PROPERTIES { - rasterDPI: raster_dpi as f32, + rasterDPI: max(dpi.0, dpi.1) as f32, ..Default::default() }; let print_control = d2d_device @@ -222,6 +221,7 @@ impl DxgiPrintContext { document_target, print_control, completion_waiter, + dpi, status_connection, status_cookie, completed: false, @@ -259,3 +259,42 @@ impl Drop for DxgiPrintContext { } } } + +#[cfg(test)] +mod tests { + use super::{resolution, DEFAULT_DPI}; + use windows::Win32::Graphics::Gdi::{DEVMODEW, DMRES_HIGH, DM_PRINTQUALITY, DM_YRESOLUTION}; + + fn dev_mode(quality: Option, y: Option) -> DEVMODEW { + let mut dev_mode = DEVMODEW::default(); + if let Some(quality) = quality { + dev_mode.dmFields |= DM_PRINTQUALITY; + dev_mode.Anonymous1.Anonymous1.dmPrintQuality = quality; + } + if let Some(y) = y { + dev_mode.dmFields |= DM_YRESOLUTION; + dev_mode.dmYResolution = y; + } + dev_mode + } + + #[test] + fn the_resolution_comes_from_the_dev_mode_and_never_from_a_quality_level() { + assert_eq!(resolution(&dev_mode(Some(600), Some(1200))), (600, 1200)); + assert_eq!(resolution(&dev_mode(Some(300), None)), (300, 300)); + assert_eq!(resolution(&dev_mode(None, Some(360))), (360, 360)); + // `DMRES_HIGH` (-4) is a quality level, not a resolution. + assert_eq!( + resolution(&dev_mode(Some(DMRES_HIGH as i16), Some(600))), + (600, 600) + ); + assert_eq!( + resolution(&dev_mode(Some(DMRES_HIGH as i16), None)), + (DEFAULT_DPI, DEFAULT_DPI) + ); + assert_eq!( + resolution(&dev_mode(None, None)), + (DEFAULT_DPI, DEFAULT_DPI) + ); + } +} diff --git a/src/printer/win_pdf.rs b/src/printer/win_pdf.rs index 792296b..3d36444 100644 --- a/src/printer/win_pdf.rs +++ b/src/printer/win_pdf.rs @@ -13,6 +13,7 @@ use windows::Win32::Graphics::Direct2D::Common::D2D_SIZE_F; use windows::Win32::System::WinRT::CreateRandomAccessStreamOnFile; use windows::Win32::System::WinRT::Pdf::PdfCreateRenderer; use windows::Win32::System::WinRT::Pdf::PDF_RENDER_PARAMS; +use windows_numerics::Matrix3x2; #[derive(Error, Debug)] /// Represents an error from [`WinPdfPrinter`]. @@ -26,6 +27,31 @@ pub enum WinPdfPrinterError { /// Failed to open the document. #[error("Failed to open the document")] FailedToOpenDocument(#[source] windows::core::Error), + /// The page does not fit the requested resolution. + #[error("The page ({dips} DIPs) cannot be rendered at {scale} times its size")] + PageSize { + /// The side of the page in device-independent pixels. + dips: f32, + /// The factor of the requested resolution over 96 dpi. + scale: f32, + }, + /// A page could not be rendered or recorded at the requested resolution. + /// + /// Direct3D 11 guarantees textures only up to 16384 texels a side + /// () + /// and Direct2D printing limits a bitmap to 65535 device units + /// (). Neither was reached when + /// pages were rendered whole up to A4 at 9600 dpi, but a driver or GPU may have its own limits. + #[error("The page could not be rendered at {render_dpi:?} dpi ({render_size_px:?} pixels)")] + RenderAtResolution { + /// The render resolution. + render_dpi: (u32, u32), + /// The render size in pixels at that resolution. + render_size_px: (u32, u32), + /// The failing call's error. + #[source] + source: windows::core::Error, + }, /// Render error. #[error("Render error")] RenderError(#[source] windows::core::Error), @@ -34,12 +60,34 @@ pub enum WinPdfPrinterError { /// A printer that uses [`Windows.Data.Pdf`](https://learn.microsoft.com/en-us/uwp/api/windows.data.pdf?view=winrt-26100) to to print PDF documents. pub struct WinPdfPrinter { printer: PrinterDevice, + override_render_dpi: Option<(u32, u32)>, } impl WinPdfPrinter { /// Create a new [`WinPdfPrinter`] for the given printer device. pub fn new(printer: PrinterDevice) -> Self { - Self { printer } + Self { + printer, + override_render_dpi: None, + } + } + + /// Override the resolution `Windows.Data.Pdf` draws a page at. By default it is the + /// resolution the job's ticket asks for ([`DxgiPrintContext::dpi`]). + pub fn override_render_dpi(&mut self, x: u32, y: u32) { + self.override_render_dpi = Some((x, y)); + } +} + +const DIP_PER_INCH: f32 = 96.0; + +/// A page side in pixels at `scale` times its size in DIPs. +fn scaled(dips: f32, scale: f32) -> Result { + let pixels = (dips * scale).ceil(); + if pixels >= 1.0 && pixels < u32::MAX as f32 { + Ok(pixels as u32) + } else { + Err(WinPdfPrinterError::PageSize { dips, scale }) } } @@ -71,15 +119,16 @@ impl WinPdfPrinter { let dxgi_device = &context.dxgi_device; let print_control = &context.print_control; let d2d_context = &context.d2d_context; + let render_dpi = self.override_render_dpi.unwrap_or(context.dpi); + let scale = ( + render_dpi.0 as f32 / DIP_PER_INCH, + render_dpi.1 as f32 / DIP_PER_INCH, + ); unsafe { let pdf_document = load_document(path)?; let pdf_renderer = PdfCreateRenderer(dxgi_device).map_err(WinPdfPrinterError::RenderError)?; - let pdf_render_options = PDF_RENDER_PARAMS { - IgnoreHighContrast: true.into(), - ..Default::default() - }; let page_count = pdf_document .PageCount() .map_err(WinPdfPrinterError::RenderError)?; @@ -89,29 +138,56 @@ impl WinPdfPrinter { .GetPage(i) .map_err(WinPdfPrinterError::RenderError)?; + // Without a destination size the page is drawn at its size in DIPs, so + // whatever Windows.Data.Pdf rasterizes (images, Type 3 glyphs) reaches the + // sheet at about 96 dpi. Draw it at the job's resolution and scale the result + // back to the page size. + let pdf_size = pdf_page.Size().map_err(WinPdfPrinterError::RenderError)?; + let render_size_px = ( + scaled(pdf_size.Width, scale.0)?, + scaled(pdf_size.Height, scale.1)?, + ); + let at_resolution = |source| WinPdfPrinterError::RenderAtResolution { + render_dpi, + render_size_px, + source, + }; + let pdf_render_options = PDF_RENDER_PARAMS { + DestinationWidth: render_size_px.0, + DestinationHeight: render_size_px.1, + IgnoreHighContrast: true.into(), + ..Default::default() + }; let command_list = d2d_context .CreateCommandList() .map_err(WinPdfPrinterError::RenderError)?; d2d_context.SetTarget(&command_list); d2d_context.BeginDraw(); - pdf_renderer - .RenderPageToDeviceContext(&pdf_page, d2d_context, Some(&pdf_render_options)) - .map_err(WinPdfPrinterError::RenderError)?; - d2d_context - .EndDraw(None, None) - .map_err(WinPdfPrinterError::RenderError)?; + // Scale by the final pixel size, not by `scale`: the size is rounded up to + // whole pixels, and this keeps the page exactly its size. + d2d_context.SetTransform(&Matrix3x2::scale( + pdf_size.Width / pdf_render_options.DestinationWidth as f32, + pdf_size.Height / pdf_render_options.DestinationHeight as f32, + )); + let rendered = pdf_renderer.RenderPageToDeviceContext( + &pdf_page, + d2d_context, + Some(&pdf_render_options), + ); + d2d_context.SetTransform(&Matrix3x2::identity()); + rendered.map_err(at_resolution)?; + d2d_context.EndDraw(None, None).map_err(at_resolution)?; command_list .Close() .map_err(WinPdfPrinterError::RenderError)?; - let pdf_size = pdf_page.Size().map_err(WinPdfPrinterError::RenderError)?; let dx_size = D2D_SIZE_F { width: pdf_size.Width as f32, height: pdf_size.Height as f32, }; print_control .AddPage(&command_list, dx_size, None, None, None) - .map_err(WinPdfPrinterError::RenderError)?; + .map_err(at_resolution)?; progress(i + 1, page_count); } } @@ -270,4 +346,26 @@ mod tests { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("test_data/test_document.pdf"); pdf.print(path.as_path(), Default::default()).unwrap(); } + + /// A4 at 1600 dpi is 13229 x 18709 px, taller than the 16384 texels Direct3D 11 allows a + /// texture. The page is still rendered whole, in one pass: `RenderPageToDeviceContext` records + /// into a command list and never allocates a page-sized texture. + #[test] + fn a_page_larger_than_a_texture_prints_at_a_high_resolution() { + let device = null_device::thread_local(); + let mut pdf = WinPdfPrinter::new(device); + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("test_data/test_document.pdf"); + pdf.override_render_dpi(1600, 1600); + pdf.print(path.as_path(), Default::default()).unwrap(); + } + + #[test] + fn a_page_side_is_scaled_and_never_overflows() { + use super::scaled; + assert_eq!(scaled(793.7, 1600.0 / 96.0).unwrap(), 13229); + assert_eq!(scaled(1122.5, 1.0).unwrap(), 1123); + assert!(scaled(0.0, 1.0).is_err()); + assert!(scaled(f32::NAN, 1.0).is_err()); + assert!(scaled(1e12, 100.0).is_err()); + } }