Printing: the printer list doesn't wait on drivers, and a job stuck on its driver can be cancelled (#5710) (#5732)

* #5710: the Print panel lists printers without asking their drivers

On Windows the printer list waited until every printer's driver said
whether it prints on both sides. A networked printer in power save can
hold that query for a long time, so the list stayed empty and Refresh
stayed unavailable. WordCraft never used the answer.

Discovery now uses craft-print's printer_queues, which lists the same
queues and default without asking any driver (storytold/craft-libs#618).
Printing still checks the job's options with the driver. Only the
craft-print pin moves; craft-ui doesn't depend on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5710: the docs say what is proven, not that the sleeping printer is fixed

Review: the progress log and gap rows stated the reported outcome as
certain, but the sleeping network printer hasn't been retested. They now
say the driver query is removed and the reported printer awaits retest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5710: craft-print pin moves to the merge of storytold/craft-libs#618

e653048 is craft-libs main's merge of #618; crates/print is identical
to the reviewed and tested head 81c2488.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5710: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it

On Windows a print job asks the printer's driver for its capabilities
before it is sent, and a networked printer in power save can hold that
query for a long time. The Print page stayed locked until the driver
answered, and quitting waited for the job before saving preferences
(CodeRabbit on #5732).

Each job now carries a craft-print Cancel token. Cancel printing
(ui.printCancel) stops a job that hasn't started reaching the spooler,
so it is never sent, and frees the Print page with the settings kept;
a job already being sent completes, and the page says to cancel it
from the system's queue. Quitting cancels jobs still waiting and waits
only for jobs already being sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5710: quitting cancels every waiting print job before it waits; Cancel asks the active job

Review: quitting cancelled and waited for jobs one by one, so a job left
waiting while an earlier one was being sent could start sending
meanwhile. Every job is now cancelled first, then only those already
being sent are waited for. Cancel printing asked every unfinished job,
so a job that had just been sent could be reported as cancelled and its
receipt lost; it now asks the job being submitted, which answers that it
was already sent.

The tests cover both orders, junk parameters for ui.printCancel, and the
kept printer list and status message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5710: craft-print pin moves to storytold/craft-libs#622 for submit_cancellable

85c8ca1 is #622's head (craft-libs main e653048 plus the Cancel token);
it moves to #622's merge on craft-libs main before this merges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5710: craft-print pin follows storytold/craft-libs#622 (230d522)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #5710: pin craft-print to merged cancellation fix

* #5710: allow print cancellation after closing the document

* #5710: correct print-cancellation status date

* #5710: clarify when a print job cannot yet be cancelled in the queue

* #5710: isolate Windows print submissions in cancellable helper processes

* #5710: strengthen helper retry and crash-cleanup regressions

* #5710: erase helper inputs before driver work

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ash
2026-11-06 08:52:57 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ae0692ee66
commit 035982e34e
13 changed files with 834 additions and 26 deletions
Generated
+4 -1
View File
@@ -1090,9 +1090,10 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=a3e0b5a9440d18f19422a9dca13516747b7d3181#a3e0b5a9440d18f19422a9dca13516747b7d3181"
dependencies = [
"craft-winprint-gdi",
"log",
"miniz_oxide 0.9.1",
"roxmltree",
"tempfile",
@@ -6814,7 +6815,9 @@ dependencies = [
"open",
"pollster 1.0.1",
"rfd",
"serde",
"serde_json",
"tempfile",
"windows",
"winit",
"winresource",
+8 -1
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -22,6 +22,8 @@ wordcraft-control-key = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11", "links"] }
egui = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true }
tempfile = "3"
log = { workspace = true }
chrono = { workspace = true }
@@ -77,7 +79,9 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
clipboard-win = { version = "5.4", features = ["std"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Pinned apart from craft-ui, which doesn't depend on it: `printer_queues`, the printer list that
# asks no driver (#5710, storytold/craft-libs#618), and `submit_cancellable` (storytold/craft-libs#622).
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "a3e0b5a9440d18f19422a9dca13516747b7d3181" }
rfd = { workspace = true }
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
# rfd itself already depends on it.
+5
View File
@@ -337,6 +337,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
file_dialog: Some(file_dialog),
printers: Some(print::discover()),
print_native: Some(print::submit()),
cancel_print: Some(print::cancel()),
clipboard_picture: Some(Box::new(clipboard::picture)),
make_default_app: default_apps::hook(),
// Personal templates live in `Templates` in the settings folder (#2852), made on first save.
@@ -439,6 +440,10 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
/// geometry, opens requested documents or the Start screen, starts the control server when
/// asked, and runs the eframe event loop.
fn main() -> eframe::Result {
#[cfg(windows)]
if let Some(code) = print::isolated::helper_mode() {
std::process::exit(code);
}
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&wordcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
+116 -20
View File
@@ -1,13 +1,21 @@
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
#[cfg(any(windows, test))]
pub(crate) mod isolated;
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
/// Returns a discovery callback that enumerates spooler printers on a named worker thread and
/// sends the list through a channel, keeping spooler calls off the UI thread.
///
/// The Print panel needs only names and the default, so the list comes from
/// `craft_print::printer_queues`, which asks no printer driver anything: on Windows a networked
/// printer in power save can hold a driver's capability query for a long time, and the list must
/// not wait on it (#5710). Submission still checks the job's options with the driver.
pub fn discover() -> Discover {
Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
let printers = craft_print::printers()
let printers = craft_print::printer_queues()
.into_iter()
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
.collect();
@@ -17,22 +25,59 @@ pub fn discover() -> Discover {
})
}
/// A submission worker and how to stop its job: `true` when it will never be sent, `false` when
/// it was already being sent.
struct Job {
worker: std::thread::JoinHandle<()>,
cancel: Box<dyn Fn() -> bool + Send>,
// Acknowledged helper cancellation permits joining it to reap the process and its input PDF.
isolated: bool,
}
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
// let process exit end an in-process Windows job or the CUPS hand-off before the spooler has it.
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
static JOBS: std::sync::Mutex<Vec<Job>> = std::sync::Mutex::new(Vec::new());
fn jobs() -> std::sync::MutexGuard<'static, Vec<Job>> {
JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Waits for the jobs already being sent, so the spooler has them before the process exits. A
/// job still waiting (on a sleeping printer's driver, say: #5710) is cancelled instead, so it is
/// never sent and quitting doesn't wait on the driver.
pub fn finish_pending_jobs() {
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
for worker in workers {
if worker.join().is_err() {
let pending = std::mem::take(&mut *jobs());
// Cancel them all first: a job left waiting while another is joined could start sending.
let finishing: Vec<_> = pending
.into_iter()
.filter(|job| {
let cancelled = (job.cancel)();
job.isolated || !cancelled
})
.collect();
for job in finishing {
if job.worker.join().is_err() {
log::error!("the print worker stopped unexpectedly during shutdown");
}
}
}
/// The Print page's Cancel: stops the job being submitted (the Print page submits one at a
/// time, the latest) unless it has started reaching the spooler. `true` when it will never be
/// sent. A job that has finished sending answers `false`, so its receipt is still shown.
pub fn cancel() -> Box<dyn Fn() -> bool> {
Box::new(|| jobs().last().is_none_or(|job| (job.cancel)()))
}
/// Submit off the UI thread, isolating Windows drivers while retaining CUPS token cancellation.
pub fn submit() -> Submit {
Box::new(|bytes, settings: Settings, collate, options| {
let (sender, receiver) = std::sync::mpsc::channel();
#[cfg(not(windows))]
let cancel = craft_print::Cancel::new();
#[cfg(windows)]
let cancel = isolated::Submission::new();
let token = cancel.clone();
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
let job = craft_print::Job {
printer: Some(settings.printer),
@@ -48,12 +93,16 @@ pub fn submit() -> Submit {
title: "WordCraft document".into(),
options,
};
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
#[cfg(not(windows))]
let sent = craft_print::submit_cancellable(&bytes, &job, &mut |_, _| {}, &token).map_err(|error| error.to_string());
#[cfg(windows)]
let sent = token.submit(&bytes, &job);
let _ = sender.send(sent);
});
if let Ok(worker) = worker {
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
jobs.retain(|job| !job.is_finished());
jobs.push(worker);
let mut jobs = jobs();
jobs.retain(|job| !job.worker.is_finished());
jobs.push(Job { worker, cancel: Box::new(move || cancel.cancel()), isolated: cfg!(windows) });
}
receiver
})
@@ -62,20 +111,67 @@ pub fn submit() -> Submit {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shutdown_waits_for_pending_submission_workers() {
/// A worker that waits until released, then reports that it finished.
fn worker() -> (std::thread::JoinHandle<()>, std::sync::mpsc::Sender<()>, std::sync::mpsc::Receiver<()>) {
let (release, wait) = std::sync::mpsc::channel();
let (completed, completion) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
wait.recv().unwrap();
completed.send(()).unwrap();
let _ = wait.recv();
let _ = completed.send(());
});
JOBS.lock().unwrap().push(worker);
let shutdown = std::thread::spawn(finish_pending_jobs);
assert!(completion.try_recv().is_err(), "the print worker is still pending");
release.send(()).unwrap();
shutdown.join().unwrap();
completion.recv().unwrap();
assert!(JOBS.lock().unwrap().is_empty(), "workers were joined before shutdown returned");
(worker, release, completion)
}
/// A cancel action that records when it was asked and answers `stops`.
fn asked(stops: bool) -> (Box<dyn Fn() -> bool + Send>, std::sync::Arc<std::sync::atomic::AtomicBool>) {
let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let set = flag.clone();
let cancel = Box::new(move || {
set.store(true, std::sync::atomic::Ordering::SeqCst);
stops
});
(cancel, flag)
}
/// Shutdown waits for a job already being sent, so the spooler has it before the process
/// exits, but not for one still waiting on a driver (a sleeping printer, #5710): that one is
/// cancelled, so it is never sent, and quitting doesn't hang on it. Every job is cancelled
/// before any is waited for, so a waiting job can't start sending meanwhile. The Print page's
/// Cancel asks only the latest job. One test, because JOBS is global.
#[test]
fn shutdown_waits_for_jobs_being_sent_and_cancels_the_rest() {
let (sending, release_sending, sent) = worker();
let (stuck, release_stuck, _) = worker();
let (stop_sending, sending_asked) = asked(false);
let (stop_stuck, cancelled) = asked(true);
// The job being sent comes first, so a one-at-a-time shutdown would join it before
// cancelling the waiting one.
*JOBS.lock().unwrap() =
vec![Job { worker: sending, cancel: stop_sending, isolated: false }, Job { worker: stuck, cancel: stop_stuck, isolated: false }];
// Cancel from the Print page asks the latest job only.
assert!(cancel()(), "the latest job was still waiting: never sent");
assert!(!sending_asked.load(std::sync::atomic::Ordering::SeqCst), "an earlier job isn't the one being submitted");
cancelled.store(false, std::sync::atomic::Ordering::SeqCst);
let (finished, shutdown) = std::sync::mpsc::channel();
std::thread::spawn(move || {
finish_pending_jobs();
let _ = finished.send(());
});
let wait = std::time::Duration::from_millis(200);
assert!(shutdown.recv_timeout(wait).is_err(), "shutdown waits for the job being sent");
assert!(cancelled.load(std::sync::atomic::Ordering::SeqCst), "the waiting job was cancelled before the wait");
release_sending.send(()).unwrap();
assert!(shutdown.recv_timeout(std::time::Duration::from_secs(10)).is_ok(), "shutdown doesn't wait for the stuck job");
sent.recv().unwrap();
assert!(JOBS.lock().unwrap().is_empty(), "shutdown took every job");
// A job that has already been sent answers false, so the Print page keeps its receipt.
let (done, release_done, finished) = worker();
release_done.send(()).unwrap();
finished.recv().unwrap();
*JOBS.lock().unwrap() = vec![Job { worker: done, cancel: Box::new(|| false), isolated: false }];
assert!(!cancel()(), "a sent job isn't reported as cancelled");
JOBS.lock().unwrap().clear();
// The stuck worker was left behind without blocking shutdown; let it end.
release_stuck.send(()).unwrap();
}
}
+592
View File
@@ -0,0 +1,592 @@
//! One Windows driver submission per process: a cancelled driver cannot retain another
//! job's provider lock. The child acknowledges its Cancel token before it exits.
use std::{
io::{BufRead, BufReader, Read, Write},
path::Path,
process::{Child, ChildStdin, Command, Stdio},
sync::{Arc, Condvar, Mutex},
time::Duration,
};
use serde::{Deserialize, Serialize};
#[cfg(windows)]
const FLAG: &str = "--wordcraft-print-worker";
const PREFIX: &str = "WORDCRAFT_PRINT:";
const MAX_MESSAGE: u64 = 64 * 1024;
const MAX_PDF: u64 = 512 * 1024 * 1024;
const CANCELLED: &str = "the print job was cancelled before anything was sent to the printer";
#[derive(Serialize, Deserialize)]
enum Duplex {
Off,
LongEdge,
ShortEdge,
}
#[derive(Serialize, Deserialize)]
struct Request {
printer: Option<String>,
copies: u32,
collate: bool,
duplex: Option<Duplex>,
grayscale: bool,
shrink: bool,
title: String,
options: Vec<(String, String)>,
}
impl Request {
/// Preserve all spooler options in a platform-independent wire representation.
fn from_job(job: &craft_print::Job) -> Self {
Self {
printer: job.printer.clone(),
copies: job.copies,
collate: job.collate,
duplex: job.duplex.map(|d| match d {
craft_print::Duplex::Off => Duplex::Off,
craft_print::Duplex::LongEdge => Duplex::LongEdge,
craft_print::Duplex::ShortEdge => Duplex::ShortEdge,
}),
grayscale: job.grayscale,
shrink: job.scaling == craft_print::Scaling::ShrinkToFit,
title: job.title.clone(),
options: job.options.clone(),
}
}
/// Restore the job without substituting printer defaults or changing copy semantics.
fn job(self) -> craft_print::Job {
craft_print::Job {
printer: self.printer,
copies: self.copies,
collate: self.collate,
duplex: self.duplex.map(|d| match d {
Duplex::Off => craft_print::Duplex::Off,
Duplex::LongEdge => craft_print::Duplex::LongEdge,
Duplex::ShortEdge => craft_print::Duplex::ShortEdge,
}),
grayscale: self.grayscale,
scaling: if self.shrink { craft_print::Scaling::ShrinkToFit } else { craft_print::Scaling::Exact },
title: self.title,
options: self.options,
}
}
}
#[derive(Serialize, Deserialize)]
enum Event {
Ready,
Cancelled,
Sending,
Done(Result<String, String>),
}
enum State {
Starting,
Waking(Arc<Mutex<Child>>),
Running(ChildStdin),
Sending(ChildStdin),
Cancelled,
Finished,
}
#[derive(Clone)]
pub(crate) struct Submission(Arc<(Mutex<State>, Condvar)>);
impl Submission {
/// Create one submission whose driver cannot run until the parent grants startup.
pub(crate) fn new() -> Self {
Self(Arc::new((Mutex::new(State::Starting), Condvar::new())))
}
/// Recover the lifecycle state after poisoning without invoking another driver call.
fn state(&self) -> std::sync::MutexGuard<'_, State> {
self.0.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Only an acknowledgement of the child's token can cancel a running helper. A timeout
/// is a refusal, never permission to kill a process that may already be sending.
pub(crate) fn cancel(&self) -> bool {
let mut state = self.state();
match &mut *state {
State::Starting => {
*state = State::Cancelled;
return true;
}
State::Waking(child) => {
let _ = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner).kill();
*state = State::Cancelled;
return true;
}
State::Cancelled => return true,
State::Finished | State::Sending(_) => return false,
State::Running(input) => {
if input.write_all(b"cancel\n").and_then(|_| input.flush()).is_err() {
return false;
}
}
}
let (state, _) = self
.0
.1
.wait_timeout_while(state, Duration::from_secs(1), |s| matches!(s, State::Running(_)))
.unwrap_or_else(std::sync::PoisonError::into_inner);
matches!(*state, State::Cancelled)
}
#[cfg(windows)]
/// Submit through this executable's private helper mode and await its final receipt.
pub(crate) fn submit(&self, pdf: &[u8], job: &craft_print::Job) -> Result<String, String> {
self.submit_with(pdf, job, |directory| {
let mut command = Command::new(std::env::current_exe()?);
command.arg(FLAG).arg(directory);
Ok(command)
})
}
/// Publish a terminal state on every result, waking a cancellation caller awaiting an ack.
fn submit_with(&self, pdf: &[u8], job: &craft_print::Job, command: impl FnOnce(&Path) -> std::io::Result<Command>) -> Result<String, String> {
let result = self.run(pdf, job, command);
let mut state = self.state();
if !matches!(*state, State::Cancelled) {
*state = State::Finished;
}
self.0.1.notify_all();
if matches!(*state, State::Cancelled) { Err(CANCELLED.into()) } else { result }
}
/// Own the private inputs and child until receipt, acknowledged cancellation or failure.
fn run(&self, pdf: &[u8], job: &craft_print::Job, command: impl FnOnce(&Path) -> std::io::Result<Command>) -> Result<String, String> {
if matches!(*self.state(), State::Cancelled) {
return Err(CANCELLED.into());
}
if pdf.len() as u64 > MAX_PDF {
return Err("the print PDF exceeds the 512 MiB helper limit".into());
}
let request = serde_json::to_vec(&Request::from_job(job)).map_err(error)?;
if request.len() as u64 > MAX_MESSAGE {
return Err("the print settings exceed the helper message limit".into());
}
let directory = tempfile::tempdir().map_err(error)?;
std::fs::write(directory.path().join("job.json"), request).map_err(error)?;
std::fs::write(directory.path().join("input.pdf"), pdf).map_err(error)?;
let child =
command(directory.path()).map_err(error)?.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().map_err(error)?;
let child = Arc::new(Mutex::new(child));
let outcome = (|| {
let (input, output) = {
let mut process = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
(process.stdin.take().ok_or("print helper has no input pipe")?, process.stdout.take().ok_or("print helper has no output pipe")?)
};
{
let mut state = self.state();
if matches!(*state, State::Cancelled) {
return Err(CANCELLED.into());
}
*state = State::Waking(child.clone());
}
let mut output = BufReader::new(output);
loop {
let message = line(&mut output)?.ok_or("the print helper stopped before it was ready")?;
let Some((_, json)) = message.split_once(PREFIX) else { continue };
if !matches!(serde_json::from_str::<Event>(json).map_err(error)?, Event::Ready) {
return Err("the print helper did not acknowledge startup".into());
}
break;
}
{
let mut state = self.state();
if matches!(*state, State::Cancelled) {
return Err(CANCELLED.into());
}
*state = State::Running(input);
if let State::Running(input) = &mut *state {
input.write_all(b"start\n").and_then(|_| input.flush()).map_err(error)?;
}
}
while let Some(line) = line(&mut output)? {
let Some((_, json)) = line.split_once(PREFIX) else { continue };
match serde_json::from_str::<Event>(json).map_err(error)? {
Event::Ready => return Err("the print helper repeated its startup acknowledgement".into()),
Event::Cancelled => {
*self.state() = State::Cancelled;
self.0.1.notify_all();
return Err(CANCELLED.into());
}
Event::Sending => {
let mut state = self.state();
let previous = std::mem::replace(&mut *state, State::Finished);
*state = match previous {
State::Running(input) | State::Sending(input) => State::Sending(input),
other => other,
};
self.0.1.notify_all();
}
Event::Done(result) => return result,
}
}
Err("the print helper stopped without a receipt".into())
})();
// A cancelled child has acknowledged that it cannot send. Other outcomes are terminal
// receipts or a broken protocol/pipe; reap the process and its private input files.
if outcome.is_err() {
let _ = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner).kill();
}
let status = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner).wait().map_err(error)?;
if outcome.is_ok() && !status.success() {
return Err("the print helper failed after its receipt".into());
}
outcome
}
}
/// Keep helper transport failures distinct from a printer's own error message.
fn error(error: impl std::fmt::Display) -> String {
format!("print helper: {error}")
}
/// Read at most one byte beyond the cap so oversized files fail without unbounded allocation.
fn bounded(mut input: impl Read, limit: u64) -> Result<Vec<u8>, String> {
let mut bytes = Vec::new();
input.by_ref().take(limit.saturating_add(1)).read_to_end(&mut bytes).map_err(error)?;
if bytes.len() as u64 > limit { Err("print helper input exceeds its limit".into()) } else { Ok(bytes) }
}
/// Read one bounded UTF-8 protocol record, distinguishing EOF from an oversized message.
fn line(input: &mut impl BufRead) -> Result<Option<String>, String> {
let mut bytes = Vec::new();
input.take(MAX_MESSAGE + 1).read_until(b'\n', &mut bytes).map_err(error)?;
if bytes.len() as u64 > MAX_MESSAGE {
return Err("print helper message exceeds its limit".into());
}
if bytes.is_empty() { Ok(None) } else { String::from_utf8(bytes).map(|s| Some(s.trim_end().to_owned())).map_err(error) }
}
/// Serialize complete events under stdout's lock so the driver and listener cannot interleave.
fn emit(event: &Event) -> Result<(), String> {
let mut output = std::io::stdout().lock();
write!(output, "{PREFIX}").map_err(error)?;
serde_json::to_writer(&mut output, event).map_err(error)?;
writeln!(output).and_then(|_| output.flush()).map_err(error)
}
/// Remove both plaintext inputs even when one fails; already-removed inputs count as success.
fn remove_inputs(directory: &Path) -> Result<(), String> {
let mut first_error = None;
for name in ["job.json", "input.pdf"] {
if let Err(failure) = std::fs::remove_file(directory.join(name))
&& failure.kind() != std::io::ErrorKind::NotFound
{
first_error.get_or_insert_with(|| error(format!("could not remove {name}: {failure}")));
}
}
first_error.map_or(Ok(()), Err)
}
/// Load and erase the private inputs before driver work, while an independent listener can cancel.
fn helper(
directory: &Path,
cancel_request: impl Fn(&craft_print::Cancel) -> bool + Send + 'static,
submit: impl FnOnce(&[u8], &craft_print::Job, &craft_print::Cancel) -> Result<String, String>,
) -> Result<(), String> {
let mut input = BufReader::new(std::io::stdin());
emit(&Event::Ready)?;
if line(&mut input)?.as_deref() != Some("start") {
return Err("print helper requires a start acknowledgement".into());
}
let cancel = craft_print::Cancel::new();
let listener = cancel.clone();
std::thread::Builder::new()
.name("print-cancel".into())
.spawn(move || {
loop {
match line(&mut input) {
Ok(Some(command)) if command == "cancel" => {
if cancel_request(&listener) {
let _ = emit(&Event::Cancelled);
std::process::exit(0);
}
if emit(&Event::Sending).is_err() {
break;
}
}
_ => break,
}
}
// If the parent disappears, never leave a waiting driver process behind. Sending
// jobs finish normally so the OS gets their pages even if the UI has gone away.
if cancel_request(&listener) {
std::process::exit(0);
}
})
.map_err(error)?;
let request: Request =
serde_json::from_slice(&bounded(std::fs::File::open(directory.join("job.json")).map_err(error)?, MAX_MESSAGE)?).map_err(error)?;
let pdf = bounded(std::fs::File::open(directory.join("input.pdf")).map_err(error)?, MAX_PDF)?;
remove_inputs(directory)?;
let result = submit(&pdf, &request.job(), &cancel);
emit(&Event::Done(result))
}
#[cfg(windows)]
/// Handle only the private first-argument flag, before logging or GUI initialization.
pub(crate) fn helper_mode() -> Option<i32> {
let mut args = std::env::args_os().skip(1);
if args.next().as_deref() != Some(std::ffi::OsStr::new(FLAG)) {
return None;
}
let result = args.next().ok_or_else(|| "print helper directory missing".to_owned()).and_then(|dir| {
helper(Path::new(&dir), craft_print::Cancel::cancel, |pdf, job, cancel| {
craft_print::submit_cancellable(pdf, job, &mut |_, _| {}, cancel).map_err(error)
})
});
if let Err(message) = &result {
let _ = emit(&Event::Done(Err(message.clone())));
}
Some(i32::from(result.is_err()))
}
#[cfg(test)]
mod tests {
use super::*;
/// A real test child exercises production pipes, token handling and process cleanup.
#[test]
#[ignore = "launched by the process protocol tests"]
fn helper_process() {
let Some(directory) = std::env::var_os("WORDCRAFT_TEST_PRINT_DIR") else { return };
if std::env::var("WORDCRAFT_TEST_PRINT_MODE").as_deref() == Ok("waking") {
std::fs::write(Path::new(&directory).join("waking"), b"starting").unwrap();
loop {
std::thread::park();
}
}
let result = helper(
Path::new(&directory),
|token| {
if std::env::var("WORDCRAFT_TEST_PRINT_MODE").as_deref() == Ok("sending") { false } else { token.cancel() }
},
|pdf, job, _token| {
assert_eq!(pdf, b"synthetic PDF");
assert_eq!(job.printer.as_deref(), Some("test queue"));
match std::env::var("WORDCRAFT_TEST_PRINT_MODE").as_deref() {
Ok("held") => {
let _provider = Mutex::new(());
let _held = _provider.lock().unwrap();
std::fs::write(Path::new(&directory).join("held"), b"driver entered").unwrap();
loop {
std::thread::park();
}
}
Ok("crash") => std::process::exit(7),
Ok("sending") => {
std::fs::write(Path::new(&directory).join("sending"), b"sending").unwrap();
while !Path::new(&directory).join("release").exists() {
std::thread::sleep(Duration::from_millis(5));
}
Ok("spooled synthetic job".into())
}
_ => Ok("spooled synthetic job".into()),
}
},
);
std::process::exit(i32::from(result.is_err()));
}
/// Launch just the ignored helper entry, without invoking printer hardware.
fn command(directory: &Path, mode: &str) -> std::io::Result<Command> {
let mut command = Command::new(std::env::current_exe()?);
let name = module_path!().split_once("::").map_or(module_path!(), |(_, name)| name);
command.args(["--ignored", "--exact", &format!("{name}::helper_process"), "--nocapture"]);
command.env("WORDCRAFT_TEST_PRINT_DIR", directory).env("WORDCRAFT_TEST_PRINT_MODE", mode);
Ok(command)
}
/// Give the child a literal printer identity to verify transport.
fn job() -> craft_print::Job {
craft_print::Job { printer: Some("test queue".into()), ..Default::default() }
}
#[test]
/// Cancelling before submission cannot launch a driver process.
fn cancelled_start_never_launches_a_helper() {
let submission = Submission::new();
assert!(submission.cancel());
let result = submission.submit_with(b"synthetic PDF", &job(), |_| Err(std::io::Error::other("must not launch")));
assert_eq!(result, Err(CANCELLED.into()));
}
#[test]
/// A cancellation during creation withholds the start grant and removes private inputs.
fn cancellation_during_startup_never_starts_the_driver() {
let submission = Submission::new();
let worker = submission.clone();
let (started, starting) = std::sync::mpsc::channel();
let (release, wait) = std::sync::mpsc::channel();
let running = std::thread::spawn(move || {
worker.submit_with(b"synthetic PDF", &job(), |directory| {
started.send(directory.to_path_buf()).unwrap();
wait.recv().unwrap();
command(directory, "held")
})
});
let directory = starting.recv_timeout(Duration::from_secs(10)).unwrap();
assert!(submission.cancel());
release.send(()).unwrap();
assert_eq!(running.join().unwrap(), Err(CANCELLED.into()));
assert!(!directory.exists(), "startup cancellation reaps the child and removes its inputs");
}
#[test]
/// A hung startup has no permission to send and can be killed without a token ack.
fn an_unresponsive_starting_process_can_be_stopped_without_an_acknowledgement() {
let submission = Submission::new();
let worker = submission.clone();
let (path_tx, path_rx) = std::sync::mpsc::channel();
let running = std::thread::spawn(move || {
worker.submit_with(b"synthetic PDF", &job(), |dir| {
path_tx.send(dir.to_path_buf()).unwrap();
command(dir, "waking")
})
});
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !directory.join("waking").exists() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(directory.join("waking").exists());
assert!(submission.cancel(), "a process without a start grant cannot send anything");
assert_eq!(running.join().unwrap(), Err(CANCELLED.into()));
assert!(!directory.exists());
}
#[test]
/// Job transport preserves every explicit setting, including Unicode and duplex direction.
fn job_options_survive_the_process_boundary() {
let expected = craft_print::Job {
printer: Some("Office λ".into()),
copies: 3,
collate: false,
duplex: Some(craft_print::Duplex::ShortEdge),
grayscale: true,
scaling: craft_print::Scaling::ShrinkToFit,
title: "A document".into(),
options: vec![("media".into(), "iso_a4_210x297mm".into())],
};
let bytes = serde_json::to_vec(&Request::from_job(&expected)).unwrap();
let received: Request = serde_json::from_slice(&bytes).unwrap();
assert_eq!(received.job(), expected);
}
#[test]
/// Retry succeeds immediately after cancellation, before joining the old worker.
fn cancelled_driver_does_not_block_the_next_process() {
let submission = Submission::new();
let worker = submission.clone();
let (path_tx, path_rx) = std::sync::mpsc::channel();
let running = std::thread::spawn(move || {
worker.submit_with(b"synthetic PDF", &job(), |dir| {
path_tx.send(dir.to_path_buf()).unwrap();
command(dir, "held")
})
});
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !directory.join("held").exists() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(directory.join("held").exists(), "the child is held inside its driver");
assert!(!directory.join("input.pdf").exists(), "the stalled driver retains no plaintext PDF");
assert!(!directory.join("job.json").exists(), "loaded settings were erased before driver work");
assert!(submission.cancel(), "a stalled driver still acknowledges cancellation");
let next = Submission::new();
assert_eq!(next.submit_with(b"synthetic PDF", &job(), |dir| command(dir, "ready")), Ok("spooled synthetic job".into()));
assert_eq!(running.join().unwrap(), Err(CANCELLED.into()));
assert!(!directory.exists(), "the reaped helper's PDF and settings were removed");
assert!(!next.cancel(), "a completed job keeps its receipt");
}
#[test]
/// A refused cancellation keeps the sending process alive until its receipt.
fn cancellation_refusal_preserves_the_sending_job() {
let submission = Submission::new();
let worker = submission.clone();
let (path_tx, path_rx) = std::sync::mpsc::channel();
let running = std::thread::spawn(move || {
worker.submit_with(b"synthetic PDF", &job(), |dir| {
path_tx.send(dir.to_path_buf()).unwrap();
command(dir, "sending")
})
});
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !directory.join("sending").exists() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(directory.join("sending").exists());
assert!(!submission.cancel(), "a sending job refuses cancellation");
assert!(matches!(*submission.state(), State::Sending(_)));
assert!(!running.is_finished(), "the child is retained until it finishes sending");
std::fs::write(directory.join("release"), b"continue").unwrap();
assert_eq!(running.join().unwrap(), Ok("spooled synthetic job".into()));
assert!(!directory.exists());
}
#[test]
/// Closing the parent's pipe stops a waiting driver and permits parent-side cleanup.
fn parent_disconnect_stops_a_waiting_driver() {
let submission = Submission::new();
let worker = submission.clone();
let (path_tx, path_rx) = std::sync::mpsc::channel();
let running = std::thread::spawn(move || {
worker.submit_with(b"synthetic PDF", &job(), |dir| {
path_tx.send(dir.to_path_buf()).unwrap();
command(dir, "held")
})
});
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !directory.join("held").exists() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(directory.join("held").exists());
*submission.state() = State::Finished; // Drops the parent's pipe as on process exit.
assert_eq!(running.join().unwrap(), Err("the print helper stopped without a receipt".into()));
assert!(!directory.exists());
}
#[test]
/// Helper failure removes its input directory; input limits reject oversize data.
fn helper_crashes_are_errors_and_inputs_are_bounded() {
let submission = Submission::new();
let mut inputs = None;
assert_eq!(
submission.submit_with(b"synthetic PDF", &job(), |dir| {
inputs = Some(dir.to_path_buf());
command(dir, "crash")
}),
Err("the print helper stopped without a receipt".into())
);
assert!(!inputs.unwrap().exists(), "a helper crash removes the PDF and settings");
assert_eq!(bounded(&b"abcd"[..], 3), Err("print helper input exceeds its limit".into()));
assert_eq!(bounded(&b"abc"[..], 3), Ok(b"abc".to_vec()));
}
/// Cleanup is repeatable and reports real failures while still attempting both inputs.
#[test]
fn input_cleanup_tolerates_missing_files_and_reports_failures() {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("job.json"), b"settings").unwrap();
std::fs::write(directory.path().join("input.pdf"), b"private PDF").unwrap();
assert_eq!(remove_inputs(directory.path()), Ok(()));
assert_eq!(remove_inputs(directory.path()), Ok(()));
assert!(!directory.path().join("input.pdf").exists());
std::fs::create_dir(directory.path().join("job.json")).unwrap();
std::fs::write(directory.path().join("input.pdf"), b"private PDF").unwrap();
let error = remove_inputs(directory.path()).unwrap_err();
assert!(error.contains("could not remove job.json"), "{error}");
assert!(!directory.path().join("input.pdf").exists(), "a settings cleanup failure does not retain the PDF");
}
}
+2
View File
@@ -746,6 +746,7 @@ impl WordApp {
// Native printing submits the selected queue on a worker; the browser owns its dialog.
"ui.printSettings" => return Some(printing::settings(&mut self.printing, p, self.services.printers.as_ref())),
"ui.print" | "ui.printSubmit" => return Some(self.print_to_system(p)),
"ui.printCancel" => return Some(printing::cancel(self)),
// File › Options › General › Make WordCraft the default… changes the user's system
// default apps (Linux) or opens system settings (Windows), so only a click on that
// button does it ([`WordApp::make_default_app`]); scripts, the control channel and
@@ -1054,6 +1055,7 @@ pub(crate) fn works_without_document(id: &str) -> bool {
| "ui.language"
| "ui.saveChanges"
| "ui.openFileDialog"
| "ui.printCancel"
| "ui.fileBrowser"
| "ui.makeDefaultApp"
| "ui.discord"
+4
View File
@@ -208,6 +208,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
if let Some(discover) = &app.services.printers {
crate::printing::show(&mut app.printing, ui, discover, |label| crate::i18n::t(label).to_owned());
}
// A job can wait long on a sleeping printer's driver; Cancel frees the page.
if app.printing.busy() && app.services.cancel_print.is_some() && ui.button(tl!("Cancel printing")).clicked() {
let _ = app.run("ui.printCancel", serde_json::json!({}));
}
app.ui.print.copies = app.printing.settings.copies;
} else if hook {
ui.label(tl!("Send the document straight to the system print dialog, or save a copy in another format below."));
+25
View File
@@ -16,6 +16,9 @@ pub fn commands(app: &crate::WordApp) -> Vec<serde_json::Value> {
"enabled":true}),
json!({"id":"ui.printSubmit", "label":"Submit print job", "location":"File › Print", "shortcut":null, "params":submit_params, "enabled":enabled}),
json!({"id":"ui.print", "label":"Print (alias of ui.printSubmit)", "location":"File › Print", "shortcut":null, "params":submit_params, "enabled":enabled}),
json!({"id":"ui.printCancel", "label":"Cancel printing", "location":"File › Print", "shortcut":null,
"params":"none. Stops the job being submitted if it hasn't started reaching the spooler (it may wait on a sleeping printer's driver); returns cancelled: true when nothing will be sent, false when the job was already being sent (cancel it from the system's print queue).",
"enabled": app.services.cancel_print.is_some() && app.printing.busy()}),
]
}
@@ -127,6 +130,28 @@ pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> Strin
}
}
/// `ui.printCancel`: stop the job being submitted before it reaches the spooler. A job can wait
/// a long time on a sleeping network printer's driver (#5710) with the print settings locked;
/// once cancelled it is never sent, so the panel is free again at once: printer list and
/// settings kept, the printers listed again.
pub fn cancel(app: &mut crate::WordApp) -> Result<serde_json::Value, String> {
if !app.printing.busy() {
return Err("no print job is being submitted".into());
}
let cancel = app.services.cancel_print.as_ref().ok_or("cancelling a print job isn't available here")?;
if !cancel() {
app.status(tl!("Cancellation could not be confirmed here. The printer driver may still be processing the job. If it appears in the system's print queue, cancel it there."));
return Ok(serde_json::json!({"cancelled": false}));
}
// The shared state keeps the job's receiver private: a fresh one drops it (the worker's
// answer, if it ever comes, goes nowhere) and keeps the user's settings.
let old = std::mem::take(&mut app.printing);
app.printing.settings = old.settings;
app.printing.printers = old.printers;
app.status(tl!("Printing cancelled. Nothing was sent to the printer."));
Ok(serde_json::json!({"cancelled": true}))
}
/// The IPP/PWG 5101.1 media name of a Layout › Size paper (the spooler's `media` option).
pub fn pwg_media(paper: &str) -> Option<&'static str> {
Some(match paper {
+3
View File
@@ -8,6 +8,9 @@ pub struct Services {
pub printers: Option<printing::Discover>,
/// Desktop: submit the PDF with the chosen settings on a worker.
pub print_native: Option<printing::Submit>,
/// Desktop: stop the print job still waiting to be sent (on a sleeping printer's driver,
/// say). `true`: it will never be sent; `false`: it was already being sent.
pub cancel_print: Option<Box<dyn Fn() -> bool>>,
/// Pick a file to open; the argument says what for (`document`, `picture`).
pub pick_open: Option<Box<dyn Fn(&str) -> Option<String>>>,
/// Pick a path to save to, given a suggested name.
+59
View File
@@ -3582,6 +3582,65 @@ fn a_successful_native_print_returns_to_the_document() {
}
}
/// #5710: a job waiting on a sleeping network printer's driver locked the Print page until the
/// driver answered. `ui.printCancel` stops it before it is sent and frees the page at once,
/// keeping the chosen printer; a job already being sent can't be stopped, and the page says so.
#[test]
fn cancelling_a_waiting_print_job_frees_the_print_page() {
use printing::Printer;
for (never_sent, close_document) in [(true, false), (false, false), (true, true), (false, true)] {
let mut a = app();
a.printing.printers = vec![Printer { name: "Office".into(), label: String::new(), default: true }];
a.printing.settings.printer = "Office".into();
a.printing.settings.copies = 3;
// The worker never answers, as when the driver doesn't: keep its sender alive.
let held = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let senders = held.clone();
a.services.print_native = Some(Box::new(move |_, _, _, _| {
let (tx, rx) = std::sync::mpsc::channel();
senders.borrow_mut().push(tx);
rx
}));
let asked = std::rc::Rc::new(std::cell::Cell::new(0));
let cancels = asked.clone();
a.services.cancel_print = Some(Box::new(move || {
cancels.set(cancels.get() + 1);
never_sent
}));
for junk in [json!({}), json!(null), json!("all"), json!([1, 2]), json!({"cancel": -1, "job": "x"})] {
assert!(a.run("ui.printCancel", junk).is_err(), "nothing to cancel before a job is submitted");
}
a.run("ui.print", json!({})).unwrap();
assert!(a.printing.busy(), "the job waits");
if close_document {
a.execute("file.close", json!({})).unwrap();
assert!(!a.has_document(), "the last document closed");
assert!(a.printing.busy(), "closing the document keeps its pending print job");
}
let enabled = |a: &WordApp| printing::commands(a).iter().any(|c| c["id"] == "ui.printCancel" && c["enabled"] == true);
assert!(enabled(&a), "Cancel is offered while the job waits");
let reply = a.execute("ui.printCancel", json!({})).unwrap();
assert_eq!(reply["cancelled"], never_sent);
assert_eq!(asked.get(), 1, "the host was asked to stop the job");
assert_eq!(a.printing.busy(), !never_sent, "never_sent {never_sent}: whether the page is still locked");
assert_eq!(a.printing.settings.printer, "Office", "the chosen printer is kept");
assert_eq!(a.printing.settings.copies, 3, "the settings are kept");
assert_eq!(a.printing.printers.len(), 1, "the printer list is kept");
let said = a.status_msg.as_ref().map(|(text, _)| text.as_str()).unwrap_or_default();
let expected = if never_sent {
"Printing cancelled. Nothing was sent to the printer."
} else {
"Cancellation could not be confirmed here. The printer driver may still be processing the job. If it appears in the system's print queue, cancel it there."
};
assert_eq!(said, expected, "never_sent {never_sent}: the status bar says what happened");
assert_eq!(enabled(&a), !never_sent);
if never_sent {
assert_eq!(a.execute("ui.printCancel", json!({})).unwrap_err(), "no print job is being submitted");
}
assert_eq!(held.borrow().len(), 1);
}
}
/// #1156: the Print page's preview fills the pane beside the settings, zooms from its own
/// bar, scrolls both ways once the page outgrows the pane, and One Page fits it again.
#[test]
+6 -2
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -1,6 +1,6 @@
# Hardware parity with Microsoft Word
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-01 · **Change:** trivial (Linux Read Aloud: a sentence a screen reader cuts off is said again); previously trivial (ink page lookup work, #3614); previously trivial (only the PDF sent to the printer has no link annotations; Save these pages as PDF keeps them, #4734); previously trivial (a document with links prints: the print job's PDF has no link annotations, #4734; previously trivial: Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-06 · **Change:** trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (the printer list no longer waits on printer drivers, #5710); previously trivial (Linux Read Aloud: a sentence a screen reader cuts off is said again); previously trivial (ink page lookup work, #3614); previously trivial (only the PDF sent to the printer has no link annotations; Save these pages as PDF keeps them, #4734); previously trivial (a document with links prints: the print job's PDF has no link annotations, #4734; previously trivial: Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -28,6 +28,10 @@ File > Print offers 2, 4, 6, 9 or 16 logical pages per physical sheet side and l
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) |
| 2026-11-06 | trivial | Windows print submissions use a separate helper per job, preserving provider serialization inside the helper. An acknowledged cancellation stops that helper, so a stalled cancelled driver cannot block a later job; normal shutdown reaps helpers and removes temporary PDFs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Startup requires an explicit start grant; after sending begins, jobs complete (#5710) |
| 2026-11-06 | trivial | File › Print: before sending starts, Cancel printing (`ui.printCancel`) stops a waiting job and frees the Print page while keeping its settings, including after the last document closes; a successfully cancelled job is never sent. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. |
| 2026-11-06 | trivial | On Windows the Print panel lists printers without asking their drivers anything, removing the driver query a networked printer in power save could hold, which kept the list empty with Refresh unavailable (#5710; likely also behind the long "Finding printers" waits of #5645 and #5413): discovery uses craft-print's `printer_queues` (storytold/craft-libs#618), and printing still checks the job's options with the driver; physical sleeping-printer output remains to be confirmed by the reporter |
| 2026-10-28 | trivial | Ink page lookup work, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change |
| 2026-11-01 | trivial | N-up and booklet print imposition (#641): integrate current print-job link handling (#4734), preserve markup preview and sheet/copy planning; readiness estimates unchanged. |
| 2026-10-30 | trivial | Only the PDF sent to the printer leaves out link annotations; Save these pages as PDF (`file.printPdf`) keeps them (#4734) |