diff --git a/ROADMAP.md b/ROADMAP.md index 452c410b..94ca375d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -145,6 +145,7 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-10 (M9, #179 follow-up):** A Windows store certificate that can't sign is no longer left out of the Sign dialog silently: `windows::list` names it with the reason and Windows error code (no private key, a P-521/Brainpool/Ed25519 key, a key CNG can't open), the picker shows it greyed and unselectable, and `sign_windows_ids` returns it under `unusable`. A key that needs a permission prompt to open is listed instead of skipped. Reported by a user whose CA-issued identity showed in Acrobat but not here; a plain .p12 import into the CNG and legacy Microsoft providers was verified to list, so the next report will carry the reason. Store test adds a key-less certificate; overall estimate unchanged (about 30–35%). - **2026-10-10 (M10.2, JPEG scanning follow-up to #729):** Skip marker fill bytes and keep reading JFIF, APP14 and ICC metadata after the frame header, stopping before scan data or at EOI. Invalid segment lengths return errors; damaged or mismatched ICC profiles retain #729's device-space fallback. Synthetic regression cases added but not run at the contributor's request. Estimates unchanged. - **2026-10-10 (M13, desktop action outputs):** Desktop Action Wizard keeps every result when input basenames repeat, or when the destination already has a result from an earlier run. It preserves the original filename and extension for a free name and adds numbered suffixes for occupied names; the bounded search returns an error rather than reusing an occupied path. A real-shell regression drives Start twice and reopens the three-page and one-page results, checking that the original inputs and earlier output remain intact. This covers the desktop writer separately from the CLI/MCP action_run repair in #877. Milestone and effort estimates unchanged. +- **2026-10-10 (L Print, #756):** Windows prints. The Print dialog lists the system's printers and its default from `Win32_Printer` (through PowerShell's CIM; `wmic` is gone from Windows 11), and Print spools the print-ready PDF: the in-box Windows.Data.Pdf renders each sheet at the printer's own resolution and System.Drawing.Printing carries copies, collation, duplex and colour as the job's settings — both reached through `powershell`, so the Rust code touches no Win32 API and the machine needs no PDF reader. This closes #756 and the "printer not showing" duplicates #160, #244, #293, #465 and #644, using the CIM approach community-tested in #452; the tests render the job's sheets through the whole WinRT path on CI (dry run, no printer needed) and, where a printer can be added, catch a real job in its paused queue. Web printing is still missing; milestone and effort estimates are unchanged. - **2026-10-10 (M3, #740):** Column select, as in Acrobat: holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle, with the Select tool, the text markup tools and Redact; Edit ▸ Column select does it with a plain drag, for Linux desktops whose window manager takes Alt-drag (Xfce, Cinnamon, MATE, KDE Plasma 5 by default). Copy gives rows top to bottom with tab-separated cells (right to left in right-to-left rows, reading order for vertical text); `text_extract` takes the same rectangle as `rect`. The new label is in all 15 catalogs. Also repaired `parity/acrobat-features.toml`, which #650 had left unparseable (duplicate keys on `form.field-list`). Still missing: column selection across pages and ⇧-click extension. Estimates unchanged. - **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the standard's additive weighted sum over the dimensions (written weights), 47% → 49%: method aligned with the standard, no new evidence. Beta ≈ 26 points, ≈ 400–700 h. diff --git a/crates/print/README.md b/crates/print/README.md index 6af6c399..a9c371e0 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -32,11 +32,15 @@ Each source page becomes a Form XObject (its content wrapped in q/Q, plus the pr annotations); sheets place them with a clip. The result is a fresh, unencrypted, garbage-collected file (callers check the print permission). -`spool` talks to CUPS: printers come from `lpstat -p -d` (the queues) and `lpstat -e` (driverless -destinations no queue exists for; CUPS builds a temporary one when the job arrives), jobs go to -`lp` with copies, collation, duplex and monochrome options. The job is piped to `lp` on stdin and -never written to a temp file, where another local user could read or swap it. Other platforms -report that printing to a printer isn't available yet; the print-ready PDF can always be saved. +`spool` talks to CUPS on macOS and Linux: printers come from `lpstat -p -d` (the queues) and +`lpstat -e` (driverless destinations no queue exists for; CUPS builds a temporary one when the +job arrives), jobs go to `lp` with copies, collation, duplex and monochrome options. The job is +piped to `lp` on stdin and never written to a temp file, where another local user could read or +swap it. On Windows the printers come from `Win32_Printer` and jobs are rendered by the in-box +`Windows.Data.Pdf` and spooled by `System.Drawing.Printing` with the driver's own settings (both +through `powershell`, so no Win32 API is called here); that job file exists for the seconds the +spooler reads it, under a fresh unpredictable name. Other platforms report that printing to a +printer isn't available yet; the print-ready PDF can always be saved. -Not yet: Windows and web spoolers, print as image, poster labels, PostScript output, colour -conversion for grayscale. +Not yet: the web spooler, print as image, poster labels, PostScript output, colour conversion for +grayscale. diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index 91f44713..dcbe66c7 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -1,7 +1,10 @@ //! The system print spooler. On macOS and Linux this is CUPS: printers come from `lpstat` — its //! queues, and the driverless destinations it can print to without one — and jobs are piped to -//! `lp` with the job options (copies, collation, duplex, colour). Other platforms report that -//! printing isn't available yet; the print-ready PDF can still be saved. +//! `lp` with the job options (copies, collation, duplex, colour). On Windows the printers come +//! from `Win32_Printer` and jobs are rendered with the in-box `Windows.Data.Pdf` and spooled +//! with the driver's own settings — both through `powershell`, so no Win32 API is called here. +//! Other platforms report that printing isn't available yet; the print-ready PDF can still be +//! saved. use crate::PrintError; @@ -309,7 +312,18 @@ pub fn printers() -> Vec { let available = lpstat_e_command().output().ok().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()); printers_parsed(&queues, available.as_deref()) } - #[cfg(not(all(unix, not(target_arch = "wasm32"))))] + #[cfg(windows)] + { + // Win32_Printer through CIM: `wmic` was removed from Windows 11 (24H2) and + // `Get-CimInstance` is on every desktop install. A machine whose PowerShell is locked + // down lists no printers and the dialog offers only Save as PDF, as with CUPS that + // isn't running. + match powershell_command(POWERSHELL_PRINTERS).output() { + Ok(o) if o.status.success() => parse_windows_printers(&String::from_utf8_lossy(&o.stdout)), + _ => Vec::new(), + } + } + #[cfg(not(any(windows, all(unix, not(target_arch = "wasm32")))))] { Vec::new() } @@ -333,19 +347,137 @@ pub fn printers_parsed(queues: &str, available: Option<&str>) -> Vec { printers } +/// The `powershell` command that runs a script. `-NoProfile` keeps a user's profile from +/// writing banners into what is parsed, `-NonInteractive` from waiting for input. +#[cfg(windows)] +fn powershell_command(script: &str) -> std::process::Command { + let mut c = std::process::Command::new("powershell"); + c.args(["-NoProfile", "-NonInteractive", "-Command", script]); + c +} + +/// The Windows printer list: one line per printer, `*` on the system default. UTF-8, through +/// the output encoding the script sets first. Only the two properties the list needs are +/// pulled, so a machine with many printers doesn't wait for the rest of each record. +#[cfg(windows)] +const POWERSHELL_PRINTERS: &str = "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); Get-CimInstance Win32_Printer -Property Name, Default | ForEach-Object { if ($_.Default) { '*' + $_.Name } else { $_.Name } }"; + +/// The Print dialog's printers from the Windows printer list: one per line, `*` before the +/// system default (a Windows queue name may not contain `*`). The first `*` wins when a broken +/// spooler marks more than one, so the dialog still has a single default to preselect. +pub fn parse_windows_printers(out: &str) -> Vec { + let mut printers: Vec = out + .lines() + .map(|l| l.strip_suffix('\r').unwrap_or(l)) + .filter(|l| !l.is_empty()) + .map(|l| match l.strip_prefix('*') { + Some(name) => Printer { name: name.to_string(), default: true }, + None => Printer { name: l.to_string(), default: false }, + }) + .collect(); + let mut seen_default = false; + for p in &mut printers { + if p.default { + p.default = !seen_default; + seen_default = true; + } + } + printers +} + /// Send a print-ready PDF to the spooler. Returns the spooler's message (the job id). pub fn submit(pdf: &[u8], job: &Job) -> Result { #[cfg(all(unix, not(target_arch = "wasm32")))] { submit_via(std::process::Command::new("lp"), pdf, job) } - #[cfg(not(all(unix, not(target_arch = "wasm32"))))] + #[cfg(windows)] + { + windows_submit(pdf, job, &[]) + } + #[cfg(not(any(windows, all(unix, not(target_arch = "wasm32")))))] { let _ = (pdf, job); Err(PrintError::Spool("printing to a printer isn't available on this platform yet; save the print-ready PDF instead".into())) } } +/// [`submit`] on Windows: the print-ready PDF goes to a job file of its own (WinRT reads from +/// a path), the embedded [`WINDOWS_PRINT_SCRIPT`] spools it, and the file is gone again however +/// the job ended. `extra_envs` reaches the script too — the tests use it for +/// `PDFCRAFT_PRINT_DRYRUN`, which renders without a printer. +#[cfg(windows)] +pub(crate) fn windows_submit(pdf: &[u8], job: &Job, extra_envs: &[(&str, &str)]) -> Result { + let path = windows_job_file(pdf)?; + let mut ps = powershell_command(WINDOWS_PRINT_SCRIPT); + for (key, value) in windows_env_vars(&path, job).into_iter().chain(extra_envs.iter().map(|(k, v)| (k.to_string(), v.to_string()))) { + ps.env(key, value); + } + let out = ps.output(); + // Remove the job file before reporting: a spooler that refused the job must not leave the + // document behind. + let _ = std::fs::remove_file(&path); + let out = out.map_err(|e| PrintError::Spool(format!("the print spooler is not available: {e}")))?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let err = if err.is_empty() { String::from_utf8_lossy(&out.stdout).trim().to_string() } else { err }; + return Err(PrintError::Spool(if err.is_empty() { "the print job was refused".into() } else { err })); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// The job file [`windows_submit`] hands to `powershell`, in this user's own temporary folder: +/// a fresh name no other local user can predict (`create_new`, with the process id and the +/// time), written before anything is spawned and removed after. WinRT reads a path, so unlike +/// the job piped to `lp` this one touches disk for the seconds the spooler reads it. +#[cfg(windows)] +fn windows_job_file(pdf: &[u8]) -> Result { + use std::io::Write as _; + let written = |e: std::io::Error| PrintError::Spool(format!("the print job could not be written: {e}")); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()); + for attempt in 0..64u32 { + let path = std::env::temp_dir().join(format!("pdfcraft-print-{}-{nanos}-{attempt}.pdf", std::process::id())); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(mut file) => { + file.write_all(pdf).map_err(written)?; + return Ok(path); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(written(e)), + } + } + Err(PrintError::Spool("the print job could not be written: no free name in the temporary folder".into())) +} + +/// The print script's environment: no arguments, so a printer name with quotes or spaces +/// survives the command line and the document's path is not visible in the process list while +/// it prints. The driver options in [`Job::options`] are CUPS' PPD keywords; on Windows the +/// driver's own per-printer preferences apply (Print ▸ Properties…), so they are not sent. +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn windows_env_vars(path: &std::path::Path, job: &Job) -> Vec<(String, String)> { + vec![ + ("PDFCRAFT_PRINT_FILE".into(), path.to_string_lossy().into_owned()), + ("PDFCRAFT_PRINT_PRINTER".into(), job.printer.clone().unwrap_or_default()), + ("PDFCRAFT_PRINT_COPIES".into(), job.copies.to_string()), + ("PDFCRAFT_PRINT_COLLATE".into(), if job.collate { "1" } else { "0" }.into()), + ( + "PDFCRAFT_PRINT_DUPLEX".into(), + match job.duplex { + Duplex::Off => "off", + Duplex::LongEdge => "long-edge", + Duplex::ShortEdge => "short-edge", + } + .into(), + ), + ("PDFCRAFT_PRINT_GRAYSCALE".into(), if job.grayscale { "1" } else { "0" }.into()), + ("PDFCRAFT_PRINT_TITLE".into(), job.title.clone()), + ] +} + +/// The embedded print script (see [`windows_submit`]). +#[cfg(windows)] +pub(crate) const WINDOWS_PRINT_SCRIPT: &str = include_str!("windows_print.ps1"); + /// [`submit`] with the spooler command given, so tests can stand in for `lp`. /// /// The job goes to `lp` on stdin, never through a file: a predictable job folder in a shared diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index b57e93b0..36caa431 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use super::*; -use crate::spool::{Duplex, Job, lp_args, parse_lpstat}; +use crate::spool::{Duplex, Job, Printer, lp_args, parse_lpstat, parse_windows_printers, windows_env_vars}; /// `n` pages of 200×300 (page 3 is landscape 300×200 when n ≥ 3); page i shows "(Page i+1)". /// Page 1 carries a printable square comment, a non-printing note, and a stamp. @@ -391,6 +391,125 @@ fn the_spoolers_reply_drops_the_file_count_of_a_job_sent_on_stdin() { assert_eq!(spool::job_message(b""), ""); } +#[test] +fn windows_printers_carry_one_default_and_keep_their_names() { + // Windows marks the system default with a `*` no queue name may contain; names hold spaces, + // parentheses and non-ASCII letters, and the lines end with CRLF. + let printers = parse_windows_printers("*Microsoft Print to PDF\r\nHP LaserJet 400 (Copy 2)\r\nКопир Canon iR\r\n\r\n*XPS via USB\r\n"); + assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true }); + assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false }); + assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false }); + // A spooler marking two defaults: the first still wins, so the dialog has one to preselect. + assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false }); + assert_eq!(parse_windows_printers("\r\n"), Vec::new()); +} + +#[test] +fn the_windows_job_is_the_environment_of_the_print_script() { + let path = std::path::Path::new("C:\\Users\\me\\AppData\\Local\\Temp\\pdfcraft-print-42.pdf"); + let job = Job { + printer: Some("HP \"Laser\" Jet".into()), + copies: 3, + duplex: Duplex::ShortEdge, + grayscale: true, + title: "Angebot.pdf".into(), + ..Job::default() + }; + let vars = windows_env_vars(path, &job); + let env = |key: &str| vars.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()).unwrap(); + assert_eq!(env("PDFCRAFT_PRINT_FILE"), "C:\\Users\\me\\AppData\\Local\\Temp\\pdfcraft-print-42.pdf"); + // Quotes and spaces in a printer name survive because nothing goes on the command line. + assert_eq!(env("PDFCRAFT_PRINT_PRINTER"), "HP \"Laser\" Jet"); + assert_eq!(env("PDFCRAFT_PRINT_COPIES"), "3"); + assert_eq!(env("PDFCRAFT_PRINT_COLLATE"), "1"); + assert_eq!(env("PDFCRAFT_PRINT_DUPLEX"), "short-edge"); + assert_eq!(env("PDFCRAFT_PRINT_GRAYSCALE"), "1"); + assert_eq!(env("PDFCRAFT_PRINT_TITLE"), "Angebot.pdf"); + // No printer chosen means the system default: an empty name, not the word "default". + let one = |j: &Job, key: &str| windows_env_vars(path, j).into_iter().find(|(k, _)| k == key).unwrap().1; + assert_eq!(one(&Job::default(), "PDFCRAFT_PRINT_PRINTER"), ""); + assert_eq!(one(&Job { duplex: Duplex::Off, ..Job::default() }, "PDFCRAFT_PRINT_DUPLEX"), "off"); + assert_eq!(one(&Job { duplex: Duplex::LongEdge, ..Job::default() }, "PDFCRAFT_PRINT_DUPLEX"), "long-edge"); +} + +/// Run a PowerShell script for the Windows-only tests: its stdout, or everything it said when +/// it failed — the tests print that as the skip reason. +#[cfg(windows)] +fn windows_powershell(script: &str, envs: &[(&str, &str)]) -> Result { + let mut c = std::process::Command::new("powershell"); + c.args(["-NoProfile", "-NonInteractive", "-Command", script]); + for (k, v) in envs { + c.env(k, v); + } + match c.output() { + Ok(o) if o.status.success() => Ok(String::from_utf8_lossy(&o.stdout).into_owned()), + Ok(o) => Err(format!("{} {}", String::from_utf8_lossy(&o.stderr).trim(), String::from_utf8_lossy(&o.stdout).trim())), + Err(e) => Err(e.to_string()), + } +} + +/// Windows: the printer list comes from the spooler. A bare machine may have no printer; the +/// list that exists has real names and at most one default. +#[cfg(windows)] +#[test] +fn windows_lists_the_printers_the_system_knows() { + let printers = spool::printers(); + assert!(printers.iter().all(|p| !p.name.trim().is_empty()), "{printers:?}"); + assert!(printers.iter().filter(|p| p.default).count() <= 1, "{printers:?}"); +} + +/// Windows: the embedded print script is valid PowerShell, checked on every machine — including +/// runners that have no printer to print to. +#[cfg(windows)] +#[test] +fn the_windows_print_script_parses() { + let path = std::env::temp_dir().join(format!("pdfcraft-print-script-{}.ps1", std::process::id())); + std::fs::write(&path, spool::WINDOWS_PRINT_SCRIPT).unwrap(); + let errors = windows_powershell( + "$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile($env:PDFCRAFT_TEST_SCRIPT, [ref]$tokens, [ref]$errors) > $null; $errors.Count", + &[("PDFCRAFT_TEST_SCRIPT", path.to_str().unwrap())], + ); + let _ = std::fs::remove_file(&path); + assert_eq!(errors.as_deref().map(str::trim), Ok("0"), "parse errors: {errors:?}"); +} + +/// Windows, end to end: the script renders the job's sheets with Windows.Data.Pdf on any +/// machine — CI included, no printer needed (the dry run writes the PNGs to the temporary +/// folder) — and, where a paused test printer can be added, a real job lands in its queue. +/// Its driver is the inbox "Generic / Text Only"; a machine that cannot add one (no rights, no +/// driver) skips that half, as the CUPS tests skip where CUPS isn't running. +#[cfg(windows)] +#[test] +fn the_print_script_renders_the_job_and_reaches_the_spooler() { + let pdf = impose(&fixture(2), &settings(vec![0, 1], Layout::Size(SizeMode::Fit))).unwrap(); + // The printer name is deliberately wrong: the dry run stops before the driver is touched. + let job = Job { printer: Some("no such printer".into()), title: "test.pdf".into(), ..Job::default() }; + let dry = spool::windows_submit(&pdf, &job, &[("PDFCRAFT_PRINT_DRYRUN", "1")]); + assert_eq!(dry.as_deref(), Ok("rendered 2 sheet(s)"), "{dry:?}"); + + const NAME: &str = "PdfCraft Test Print"; + let added = windows_powershell( + &format!( + "if (-not (Get-Printer -Name '{NAME}' -ErrorAction SilentlyContinue)) {{ Add-Printer -Name '{NAME}' -DriverName 'Generic / Text Only' -PortName 'FILE:' }}; Set-Printer -Name '{NAME}' -IsPaused $true" + ), + &[], + ); + if let Err(why) = added { + eprintln!("skipping the real print: the test printer could not be prepared: {why}"); + return; + } + let job = Job { printer: Some(NAME.into()), title: "test.pdf".into(), ..Job::default() }; + let sent = spool::submit(&pdf, &job); + let queued = windows_powershell( + "if (Get-PrintJob -Name 'PdfCraft Test Print') { (Get-PrintJob -Name 'PdfCraft Test Print' | Measure-Object).Count } else { 0 }", + &[], + ); + let cleaned = windows_powershell("Get-PrintJob -Name 'PdfCraft Test Print' | Remove-PrintJob; Remove-Printer -Name 'PdfCraft Test Print'", &[]); + assert!(sent.is_ok(), "{sent:?}"); + assert_eq!(queued.as_deref().map(str::trim), Ok("1"), "the job is in the paused printer's queue: {queued:?}"); + assert!(cleaned.is_ok(), "the test printer is removed again: {cleaned:?}"); +} + /// A PPD shaped like a Fiery's: installable options, multi-line PostScript in the choices, Latin-1 /// labels, options the Print dialog sets itself. const FIERY_PPD: &[u8] = b"*PPD-Adobe: \"4.3\"\n\ diff --git a/crates/print/src/windows_print.ps1 b/crates/print/src/windows_print.ps1 new file mode 100644 index 00000000..04f506ed --- /dev/null +++ b/crates/print/src/windows_print.ps1 @@ -0,0 +1,136 @@ +# Print a print-ready PDF on Windows (issue #756). The job arrives in PDFCRAFT_* environment +# variables, never as arguments: printer names contain spaces and quotes, and the document's +# path must not be visible in the process list while it prints. +# +# The in-box Windows.Data.Pdf renders each sheet at the printer's own resolution and +# System.Drawing.Printing spools it with the driver's settings — copies, collation, duplex and +# colour reach the spooler as the job's, the way `lp -o` sends them on CUPS. No PDF reader is +# needed, and the calling code touches no Win32 API. +# +# PDFCRAFT_PRINT_DRYRUN=1 renders every sheet to a PNG in a temporary folder and prints +# nothing: the tests run the whole rendering path on machines without a printer, CI included. +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) + +Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName System.Runtime.WindowsRuntime +try { + $null = [Windows.Data.Pdf.PdfDocument, Windows.Data.Pdf, ContentType = WindowsRuntime] +} catch { + throw 'this Windows installation cannot render PDFs: the Windows.Data.Pdf component is missing' +} +$null = [Windows.Storage.StorageFile, Windows.Storage, ContentType = WindowsRuntime] + +# Await the WinRT asynchronous APIs from .NET Framework's PowerShell. AsTask is an extension +# method: found by reflecting over the static class — with `.`, an instance call on the type +# object, since `GetMethods` is not static and `::` would not find it. +$type = [System.WindowsRuntimeSystemExtensions] +$opTask = $type.GetMethods() | Where-Object { + $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' +} | Select-Object -First 1 +$actTask = $type.GetMethods() | Where-Object { + $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncAction' +} | Select-Object -First 1 +function AwaitOp($operation, $resultType) { + $task = $opTask.MakeGenericMethod($resultType).Invoke($null, @($operation)) + $task.Wait() + $task.Result +} +function AwaitAct($action) { + $task = $actTask.Invoke($null, @($action)) + $task.Wait() +} + +# One page rendered the size it will be drawn (`$drawW` × `$drawH`, hundredths of an inch), at +# `$dpi` dots per inch, aspect-fitted. The caller disposes the bitmap. +function Render-Sheet($page, $drawW, $drawH, $dpi) { + $w = $page.Size.Width + $h = $page.Size.Height + $scale = [Math]::Min($drawW / 100.0 * 96.0 / $w, $drawH / 100.0 * 96.0 / $h) + $options = [Windows.Data.Pdf.PdfPageRenderOptions]::new() + $options.DestinationWidth = [int][Math]::Ceiling($w * $scale * $dpi / 96.0) + $options.DestinationHeight = [int][Math]::Ceiling($h * $scale * $dpi / 96.0) + $stream = [System.IO.MemoryStream]::new() + try { + $random = [System.IO.WindowsRuntimeStreamExtensions]::AsRandomAccessStream($stream) + AwaitAct ($page.RenderToStreamAsync($random, $options)) + $stream.Position = 0 + # Copy out of the stream: GDI+ reads an Image lazily and needs its stream alive, so the + # render below releases the stream with the function. + $png = [System.Drawing.Image]::FromStream($stream) + try { return [System.Drawing.Bitmap]::new($png) } finally { $png.Dispose() } + } finally { + $stream.Dispose() + } +} + +$source = AwaitOp ([Windows.Storage.StorageFile]::GetFileFromPathAsync($env:PDFCRAFT_PRINT_FILE)) ([Windows.Storage.StorageFile]) +$document = AwaitOp ([Windows.Data.Pdf.PdfDocument]::LoadFromFileAsync($source)) ([Windows.Data.Pdf.PdfDocument]) +if ($document.PageCount -lt 1) { throw 'the print-ready PDF has no pages' } + +if ($env:PDFCRAFT_PRINT_DRYRUN -eq '1') { + # Every sheet rendered as the printer would take it, to prove the path without one. + $folder = Join-Path ([System.IO.Path]::GetTempPath()) ('pdfcraft-dryrun-' + $PID) + $null = New-Item -ItemType Directory -Force $folder + for ($i = 0; $i -lt $document.PageCount; $i++) { + $page = $document.GetPage($i) + try { + $bitmap = Render-Sheet $page 750 1000 150 + try { $bitmap.Save((Join-Path $folder "sheet-$i.png"), [System.Drawing.Imaging.ImageFormat]::Png) } finally { $bitmap.Dispose() } + } finally { + $page.Dispose() + } + } + Write-Output ('rendered ' + $document.PageCount + ' sheet(s)') + return +} + +$printer = [System.Drawing.Printing.PrintDocument]::new() +$printer.DocumentName = $env:PDFCRAFT_PRINT_TITLE +$settings = $printer.PrinterSettings +if ($env:PDFCRAFT_PRINT_PRINTER) { + try { + $settings.PrinterName = $env:PDFCRAFT_PRINT_PRINTER + } catch { + throw ('the printer is not available: ' + $env:PDFCRAFT_PRINT_PRINTER) + } +} +$settings.Copies = [Math]::Min(32767, [Math]::Max(1, [int]$env:PDFCRAFT_PRINT_COPIES)) +$settings.Collate = ($env:PDFCRAFT_PRINT_COLLATE -eq '1') +$settings.Duplex = switch ($env:PDFCRAFT_PRINT_DUPLEX) { + 'long-edge' { [System.Drawing.Printing.Duplex]::Horizontal } + 'short-edge' { [System.Drawing.Printing.Duplex]::Vertical } + default { [System.Drawing.Printing.Duplex]::Simplex } +} +if ($env:PDFCRAFT_PRINT_GRAYSCALE -eq '1') { $printer.DefaultPageSettings.Color = $false } + +# One sheet at a time: its page is rendered when the driver asks for it, so a long document +# holds only one page's bitmap in memory. +$script:sheets = 0 +$printer.add_PrintPage({ + param($sender, $e) + if ($script:sheets -ge $document.PageCount) { $e.HasMorePages = $false; return } + $page = $document.GetPage($script:sheets) + $bitmap = $null + try { + # The driver's page area is in hundredths of an inch; Render-Sheet fits the page into + # it, so this is the same scale, turned back into hundredths for the draw rectangle. + $area = $e.MarginBounds + $w = $page.Size.Width + $h = $page.Size.Height + $scale = [Math]::Min($area.Width / 100.0 * 96.0 / $w, $area.Height / 100.0 * 96.0 / $h) + $dpi = [Math]::Min(600, [Math]::Max(72, $e.Graphics.DpiX)) + $bitmap = Render-Sheet $page $area.Width $area.Height $dpi + $drawW = $w * $scale / 96.0 * 100.0 + $drawH = $h * $scale / 96.0 * 100.0 + $box = [System.Drawing.RectangleF]::new($area.Left + ($area.Width - $drawW) / 2.0, $area.Top + ($area.Height - $drawH) / 2.0, $drawW, $drawH) + $e.Graphics.DrawImage($bitmap, $box) + } finally { + if ($bitmap) { $bitmap.Dispose() } + $page.Dispose() + } + $script:sheets++ + $e.HasMorePages = ($script:sheets -lt $document.PageCount) +}) +$printer.Print() +Write-Output ('spooled ' + $document.PageCount + ' sheet(s)') diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index e6465d2c..22eaa744 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -108,12 +108,12 @@ fn view_history_select_all_and_find_options() { h.state_mut().views[0].open_find(); h.state_mut().views[0].find.as_mut().unwrap().query = "page".into(); h.state_mut().views[0].rerun_find(); - for _ in 0..60 { + // Find runs in the background: wait for it by a deadline, not a frame count, which a loaded + // machine (the whole workspace testing at once) can use up before the last page is searched. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while std::time::Instant::now() < deadline && h.state().views[0].find.as_ref().unwrap().matches.len() < 15 { h.run_steps(2); std::thread::sleep(std::time::Duration::from_millis(5)); - if h.state().views[0].find.as_ref().unwrap().matches.len() >= 15 { - break; - } } assert_eq!(h.state().views[0].find.as_ref().unwrap().matches.len(), 15); { diff --git a/docs/gaps.md b/docs/gaps.md index d1e90e34..262e4c7d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -22,7 +22,7 @@ update the numbers. | 1 | Encrypted files we write don't open in Acrobat | file format | 8–15 | [file-format-parity.md](file-format-parity.md) | | 2 | Rendering is borrowed and its fidelity unmeasured | feature, spec | 110–190 | [pdf-spec-parity.md](pdf-spec-parity.md) | | 3 | Editing existing text is fragile on real files | feature | 100–180 | [target-app-parity.md](target-app-parity.md) | -| 4 | No printing on Windows or the web | feature, platform | 15–30 | [hardware-parity.md](hardware-parity.md) | +| 4 | No printing on the web | feature, platform | 15–30 | [hardware-parity.md](hardware-parity.md) | | 5 | Chinese UI shows missing-glyph boxes in releases | localization | 6–12 | [localization-parity.md](localization-parity.md) | | 6 | Open-issue backlog of wrong results in shipped features | stability | 45–80 | this file | | 7 | OCR reads only unaccented Latin | feature | 40–75 | [file-format-parity.md](file-format-parity.md) | @@ -97,12 +97,14 @@ update the numbers. how a document looks. - **Estimate:** 100–180 h (the longest feature pole, M7). **Doc:** [target-app-parity.md](target-app-parity.md). -### 4. No printing on Windows or the web +### 4. No printing on the web -- **Missing:** a Windows spooler (and printer properties), browser printing. -- **Evidence:** [#756](https://github.com/storytold/pdfcraft/issues/756): on Windows 11 the Print - dialog only saves a PDF. `crates/print/README.md`: "Not yet: Windows and web spoolers". -- **Impact:** Windows is most of Acrobat's market. Printing is a P0 workflow. +- **Missing:** browser printing. +- **Evidence:** the web build has no spooler: off Windows and unix, `spool::submit` reports that + printing to a printer isn't available and only the print-ready PDF can be saved. + `crates/print/README.md`: "Not yet: the web spooler". +- **Impact:** the web build cannot send a job to a printer; printing is a P0 workflow. Printing on + Windows shipped with [#756](https://github.com/storytold/pdfcraft/issues/756). - **Estimate:** 15–30 h. **Doc:** [hardware-parity.md](hardware-parity.md). ### 5. Chinese UI shows missing-glyph boxes in releases diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 1e68f0bf..1bb06ca6 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -31,7 +31,7 @@ printing are counted in their feature areas). **Platforms ≈ 70% ready, 15–30 | Platform | Acrobat | PdfCraft | Packaging | Gaps | |---|---|---|---|---| | macOS | yes | yes | signed and notarized DMG | — | -| Windows (x64, x86, ARM64) | yes (x64, ARM64) | yes | signed installers | printing (#756) | +| Windows (x64, x86, ARM64) | yes (x64, ARM64) | yes | signed installers | — | | Linux | no | yes | AppImage, deb, rpm, Flatpak, tarball | Chinese faces in Flatpak (#689) | | FreeBSD | no | yes | package | — | | Web | Acrobat online (cloud) | yes (WASM, offline in the browser) | static site | printing, OCR, crash recovery, PKCS #12 signing, hosted deployment | @@ -41,3 +41,4 @@ printing are counted in their feature areas). **Platforms ≈ 70% ready, 15–30 | Date | Change | Summary | |---|---|---| | 2026-10-10 | major | Created from the code, `vendor/README.md`, `.github/workflows/release.yml`, the print crate's README and user issues | +| 2026-10-10 | minor | Windows printing shipped (#756): the Windows row's gap is closed; the web row keeps "printing" | diff --git a/docs/roadmap.md b/docs/roadmap.md index 216270a7..aed24a3d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -21,7 +21,7 @@ exchange with Acrobat users or a missing sub-case is `partial, not blocking` (a | Core workflow | Works end to end? | Evidence | Hours to pass | |---|---|---|---| -| View, search and print | yes | Render, find, select, panels, tiles; 500-page scrolling test; Print with CUPS on macOS and Linux (Windows printing missing, #756, a beta item). Rendering borrowed from `hayro` | 0 | +| View, search and print | yes | Render, find, select, panels, tiles; 500-page scrolling test; Print with CUPS on macOS/Linux and the Windows spooler (web printing missing, a beta item). Rendering borrowed from `hayro` | 0 | | Organize, combine and split pages | yes | `organize.*` P0s shipped with tests; links, fields, layers and bookmarks carried over; open fixes #817, #821 are edge cases | 0 | | Comment and review | yes | Every markup type with appearances, replies, status, XFDF/FDF; comments saved in the PDF reopen in other readers. XFDF fidelity bugs #809, #819 | 0 | | Fill and sign forms | yes | All AcroForm field types, Acrobat's AF functions, Fill & Sign, PAdES signatures validated with `pdfsig`/OpenSSL | 0 | @@ -42,7 +42,7 @@ In order. Each item names its entry in [gaps.md](gaps.md). damaging), then Create PDF from Office and a Word export that keeps layout (gap 8, #773). 2. **Fix the crash and wrong-result reports** filed against shipped features (gap 6), starting with the panic in #816. -3. **Printing on Windows** (gap 4, #756) and **a Chinese UI face in releases** (gap 5, #826). +3. **A Chinese UI face in releases** (gap 5, #826). 4. **A fidelity harness against Acrobat, then the renderer decision** (gaps 12 and 2). Owner decision needed: our own M2 devices, or #841's proposal as the bridge. 5. **Editing existing content** (gap 3, M7): fonts, subsets, reflow, CJK and RTL. @@ -74,7 +74,7 @@ total matches the parity estimate. | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, CUPS spooler, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; Create PDF from multiple files (PDFs, images and text, edited as pages in the grid). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, Windows/web printing | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; Create PDF from multiple files (PDFs, images and text, edited as pages in the grid). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, web printing | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools).
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dc7443ad..2c46958a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -207,7 +207,7 @@ full parity in that area. | I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, despeckle, camera images, MRC compression, scanners | | J Create and export | 34 | 12 | 3 | 39.7% | 25% | 55–100 | Create from images, multipage TIFF, text, clipboard, blank, many files at once; export PNG/JPEG/TIFF, text, RTF; Word and HTML partial. Missing: Office/HTML/web/PostScript to PDF, Excel and PowerPoint export, Word layout fidelity (#773), SVG/XML/EPS/PS/JPEG 2000 export, accessible text | | K Optimize and standards | 58 | 15 | 10 | 34.5% | 20% | 65–115 | Reduce File Size and the Optimizer (images, discards, clean-up, space audit); PDF/A-2b/3b verify and convert (partial). Missing: Preflight (profiles, fixups, reports), PDF/A-1/4 and a/u levels, PDF/X, PDF/UA, PDF/E, PDF/VT, transparency flattening, font unembedding, monochrome JBIG2/CCITT | -| L Print | 39 | 15 | 1 | 39.7% | 35% | 30–55 | Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, print-ready PDF, CUPS on macOS/Linux. Missing: **printing on Windows (#756) and the web**, print as image, PostScript, all print-production tools (output preview, separations, ink manager, printer marks, colour conversion) | +| L Print | 39 | 15 | 1 | 39.7% | 35% | 30–55 | Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, print-ready PDF, CUPS on macOS/Linux and the Windows spooler (#756). Missing: **printing on the web**, print as image, PostScript, all print-production tools (output preview, separations, ink manager, printer marks, colour conversion) | | M Accessibility | 61 | 38 | 1 | 63.1% | 30% | 40–70 | Accessibility Checker (all 32 rules, report, fixes), alternate text, title and language. Missing: autotag, Tags/Order/Content panels, Reading Order tool, artifact marking, table editor, Read Out Loud, full keyboard operation of the app, screen-reader audit | | N Automation, preferences, misc | 75 | 20 | 5 | 30.0% | 35% | 25–45 | CLI, MCP, UI control channel, 141 automation tools, Action Wizard (run, create), compare (text, visual, report). Missing: most Preferences pages, side-by-side compare, Action Wizard management, custom commands, AI providers, rich media/3D, hosted web app | | **All** | **808** | **423** | **74** | **56.9%** | **≈ 49%** | **610–1,100** | Plus 175–325 h of cross-cutting work (dimension table) | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f9d5f61a..2438bcc4 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7866,8 +7866,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["printers", "printer_options", "doc_print"] -evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/print.rs::print_dialog_shows_the_printer_driver_options"] -notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; Save as PDF; Windows spooler pending. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job" +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::windows_lists_the_printers_the_system_knows", "crates/print/src/tests.rs::a_print_job_reaches_the_windows_spooler", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/print.rs::print_dialog_shows_the_printer_driver_options"] +notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows the queues and the default come from Win32_Printer (PowerShell CIM) and jobs are rendered with Windows.Data.Pdf and spooled with the driver's own settings; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job" [[feature]] id = "print.copies-collate" @@ -7878,8 +7878,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"] -notes = "lp -n, collate option" +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::the_windows_job_is_the_environment_of_the_print_script", "crates/print/src/tests.rs::a_print_job_reaches_the_windows_spooler"] +notes = "lp -n, collate option; on Windows the driver's Copies and Collate settings, as the job's" [[feature]] id = "print.page-range" @@ -7938,8 +7938,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"] -notes = "long/short edge via the spooler" +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::the_windows_job_is_the_environment_of_the_print_script", "crates/print/src/tests.rs::a_print_job_reaches_the_windows_spooler"] +notes = "long/short edge via the spooler: lp -o sides=…, and the Windows driver's Duplex setting" [[feature]] id = "print.n-up" @@ -8034,8 +8034,8 @@ milestone = "M10" status = "partial" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"] -notes = "asks the printer for monochrome; the PDF itself stays in colour" +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::the_windows_job_is_the_environment_of_the_print_script"] +notes = "asks the printer for monochrome (lp -o, and the Windows page settings as the job's); the PDF itself stays in colour" [[feature]] id = "print.print-as-image"