Merge pull request #15 from songzhihui/fix/pdf-stream-loading

fix: load PDF print jobs on demand through a read-only file stream
This commit is contained in:
skylar taylor-barrick authored and GitHub committed 2026-11-08 17:38:38 +01:00
commit ea03a9df95
3 files changed
+147 -11

No files matched your search

+1
View File
@@ -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",
+5
View File
@@ -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.
+141 -11
View File
@@ -7,8 +7,10 @@ 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;
@@ -70,16 +72,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 +120,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<IRandomAccessStream, WinPdfPrinterError> {
// 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<PdfDocument, WinPdfPrinterError> {
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;