diff --git a/Cargo.toml b/Cargo.toml index f9257ce..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] @@ -48,6 +49,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/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. diff --git a/src/printer/dxgi.rs b/src/printer/dxgi.rs index 588d721..a7587eb 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; @@ -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( @@ -151,8 +149,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)?; @@ -163,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) @@ -182,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 @@ -219,6 +221,7 @@ impl DxgiPrintContext { document_target, print_control, completion_waiter, + dpi, status_connection, status_cookie, completed: false, @@ -256,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 aa47e45..3d36444 100644 --- a/src/printer/win_pdf.rs +++ b/src/printer/win_pdf.rs @@ -7,10 +7,13 @@ use std::path::Path; use thiserror::Error; use windows::core::HSTRING; use windows::Data::Pdf::PdfDocument; -use windows::Storage::StorageFile; +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; +use windows_numerics::Matrix3x2; #[derive(Error, Debug)] /// Represents an error from [`WinPdfPrinter`]. @@ -24,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), @@ -32,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 }) } } @@ -69,24 +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 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)?; - let pdf_render_options = PDF_RENDER_PARAMS { - IgnoreHighContrast: true.into(), - ..Default::default() - }; let page_count = pdf_document .PageCount() .map_err(WinPdfPrinterError::RenderError)?; @@ -96,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); } } @@ -127,8 +196,145 @@ impl WinPdfPrinter { } } +// 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 { + // 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()); + // 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() + .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 file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .attributes(2) + .open(&path) + .unwrap(); // FILE_ATTRIBUTE_HIDDEN + 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] + fn file_stream_reads_the_end_of_a_large_file_on_demand() { + 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())); + let file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .unwrap(); + let path = scopeguard::guard(path, |path| { + let _ = std::fs::remove_file(path); + }); + let size = 256 * 1024 * 1024; + { + 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(); + assert_eq!(stream.Size().unwrap(), size); + assert!(stream.CanRead().unwrap()); + assert!(!stream.CanWrite().unwrap()); + let input = stream.GetInputStreamAt(size - 1).unwrap(); + let buffer = Buffer::Create(1).unwrap(); + let read = input + .ReadAsync(&buffer, 1, InputStreamOptions::None) + .unwrap(); + 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(); + } + + #[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 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(error)) + if error.kind() == std::io::ErrorKind::InvalidInput + )); + } + + #[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; @@ -140,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()); + } }