Merge pull request #783 from storytold/codex/craft-print-onboarding

Print: select printers and submit jobs through craft-print
This commit is contained in:
skylar taylor-barrick authored and GitHub committed 2026-10-12 09:46:52 -06:00
commit a381c93e8d
16 files changed
+658 -276

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
# Exercise the native print host and all UI tests on PRs; the broad FreeBSD/ARM64 gates
# stay on their existing nightly/release schedule.
name: Printing
on:
pull_request:
paths:
- 'Cargo*'
- 'apps/wordcraft/**'
- 'apps/wordcraft-web/**'
- 'crates/ui-egui/**'
- '.github/workflows/printing.yml'
push:
branches: [main]
paths:
- 'Cargo*'
- 'apps/wordcraft/**'
- 'apps/wordcraft-web/**'
- 'crates/ui-egui/**'
- '.github/workflows/printing.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: printing-${{ github.ref }}
cancel-in-progress: true
jobs:
native:
name: Print (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 90
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: '0'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Linux build and software-rendering dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Formatting
run: cargo fmt --all -- --check
- name: Native app and UI Clippy
run: cargo clippy -p wordcraft -p wordcraft-ui-egui --all-targets --locked -- -D warnings
- name: Native app and UI tests
run: cargo test -p wordcraft -p wordcraft-ui-egui --locked
- name: Browser compatibility
if: runner.os == 'Linux'
run: cargo check -p wordcraft-web --target wasm32-unknown-unknown --locked
Generated
+18
View File
@@ -843,6 +843,22 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d"
dependencies = [
"tempfile",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d"
dependencies = [
"egui",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -5086,6 +5102,7 @@ version = "0.6.1"
dependencies = [
"arboard",
"clipboard-win",
"craft-print",
"dispatch2",
"eframe",
"egui",
@@ -5321,6 +5338,7 @@ dependencies = [
name = "wordcraft-ui-egui"
version = "0.6.1"
dependencies = [
"craft-ui",
"egui",
"egui_kittest",
"image",
+4
View File
@@ -4,6 +4,8 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
agent control. This page is the summary; the assessment is
[`docs/target-app-parity.md`](docs/target-app-parity.md), the work list is
@@ -200,6 +202,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | File › Print: page preview, page range (all, current, selected, custom 1-3,5), odd/even, copies and collate go into the printed PDF (`file.printPdf`, `ui.print` params); printer, duplex and pages per sheet are still the system dialog's (#641, stage 1) |
| 2026-10-11 | A vertically merged table cell (eraser on a horizontal border) lays out, shows and clicks as one cell spanning its rows (#640) |
| 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) |
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
| 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) |
| 2026-10-11 | Find and Replace special characters (`^p`, `^t`, `^#`, `^&` …) and Use wildcards (ranges, counts, word anchors, groups with `\1` in Replace); `^p` finds and splits paragraphs; Special ▾ menu and search options in the dialog (#543) |
| 2026-10-11 | Typing parity (#546): `+---+` Enter makes a table, `> ` `-> ` `=> ` arrow bullets, smileys inside words (`ok:)`), Undo right after a border line brings the characters back |
@@ -300,6 +303,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-12 | trivial | Progress log: large-table scrolling (#811) |
| 2026-10-11 | trivial | Progress log: security review fixes (temp files, exported link schemes) |
| 2026-10-11 | trivial | Progress log: Find and Replace special characters and wildcards (#543) |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | trivial | Progress log: Windows DX12 shaders compiled with FXC (#603) |
| 2026-10-11 | trivial | Progress log: Insert › SmartArt (#497); catalog 429/451 → 437/458 (95.4%) |
| 2026-10-11 | trivial | Progress log: Outline view (#493) |
+3 -3
View File
@@ -54,6 +54,8 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
wl-clipboard-rs = "0.9"
[target.'cfg(target_os = "windows")'.dependencies]
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
open = "5"
# The system's Animation effects setting (Settings › Accessibility › Visual effects), read through
# the Windows Runtime's UISettings: a safe API, where the Win32 call needs unsafe, which our
# workspace forbids. Already in the build through wgpu and AccessKit, at this version; this adds
@@ -65,6 +67,7 @@ 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 = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
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.
@@ -74,6 +77,3 @@ pollster = "0.4"
arboard = { workspace = true, features = ["image-data"] }
# The control server's random key.
getrandom = "0.3"
# File › Print opens the PDF in the system's default viewer (`xdg-open`/`gio` on Linux and BSD, the
# file association on Windows, `open` on macOS) without blocking; already in the tree via egui_kittest.
open = "5.4"
+3 -1
View File
@@ -61,7 +61,7 @@ fn xdg_mime_args(desktop_id: &str) -> Vec<String> {
/// The `applications` folders the desktop looks for entries in, from `XDG_DATA_HOME` (default
/// `~/.local/share`) and `XDG_DATA_DIRS` (default `/usr/local/share:/usr/share`). Relative paths
/// are ignored, as the XDG Base Directory spec says.
#[cfg(any(all(unix, not(target_os = "macos")), test))]
#[cfg(all(unix, any(not(target_os = "macos"), test)))]
fn application_dirs(data_home: Option<&str>, data_dirs: Option<&str>, home: Option<&str>) -> Vec<std::path::PathBuf> {
use std::path::{Path, PathBuf};
let absolute = |s: &&str| Path::new(s).is_absolute();
@@ -130,6 +130,7 @@ mod xdg {
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::path::PathBuf;
use super::*;
@@ -163,6 +164,7 @@ mod tests {
assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line");
}
#[cfg(unix)]
#[test]
fn application_dirs_follow_the_xdg_spec() {
let p = |s: &str| PathBuf::from(s);
+10 -3
View File
@@ -54,7 +54,7 @@ impl eframe::App for App {
let closing = ctx.input(|i| i.viewport().close_requested());
if closing && !self.0.close_requested() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
} else if closing || self.0.quit_requested {
} else if (closing || self.0.quit_requested) && !self.0.printing.busy() {
// On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in
// `-[_NSTouchBarFinderObservation invalidate]`. Command-Q calls `terminate:`. Once
// the close is allowed, take that path instead of dropping the window on this turn.
@@ -78,6 +78,7 @@ impl eframe::App for App {
self.3.show(frame);
}
fn on_exit(&mut self) {
print::finish_pending_jobs();
save_prefs(&self.0);
if let Some(key_file) = &self.2 {
key_file.remove();
@@ -95,11 +96,16 @@ impl eframe::App for App {
#[cfg(target_os = "macos")]
fn schedule_macos_terminate(app: &WordApp, key_file: Option<&control_server::KeyFile>) {
use std::sync::atomic::{AtomicBool, Ordering};
if app.printing.busy() {
return;
}
static QUIT: AtomicBool = AtomicBool::new(false);
if QUIT.swap(true, Ordering::Relaxed) {
return;
}
log::info!("window close: quitting");
// This path can bypass on_exit. Join the owned workers before termination and cleanup.
print::finish_pending_jobs();
save_prefs(app);
if let Some(key_file) = key_file {
key_file.remove();
@@ -193,13 +199,14 @@ fn save_prefs(app: &WordApp) {
}
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print
/// through the system's PDF viewer (`print`, #15), the system clipboard's text, formatted copies,
/// through the system spooler (`print`), the system clipboard's text, formatted copies,
/// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting
/// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295).
fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
Services {
file_dialog: Some(file_dialog),
print: Some(print::hook()),
printers: Some(print::discover()),
print_native: Some(print::submit()),
clipboard_picture: Some(Box::new(clipboard::picture)),
make_default_app: default_apps::hook(),
animations_enabled: Some(animations::hook(ctx)),
+63 -216
View File
@@ -1,229 +1,76 @@
//! Desktop printing (#15): File › Print hands the document to the system's print flow.
//!
//! The app renders the document to PDF (`ui.print`, the Print button on File › Print) and calls
//! [`Services::print`] with the bytes. Here they go to a fresh temporary file
//! (`wordcraft-print-….pdf` in the OS temp folder), which is then opened without waiting for it:
//! in Preview on macOS (`open -a Preview`), and in the default PDF app elsewhere (`xdg-open` and
//! friends on Linux and BSD, the file association on Windows, via the `open` crate). The user
//! prints from there through the system print dialog.
//!
//! The viewer reads the file after we return, so it can't be removed right away: files older than
//! [`KEEP`] are cleaned up off the UI thread when the app starts and after each print.
//!
//! [`Services::print`]: wordcraft_ui_egui::Services::print
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Temporary print files start with this and end with `.pdf`.
const PREFIX: &str = "wordcraft-print-";
/// How long a print file is kept for its viewer before the clean-up removes it.
const KEEP: Duration = Duration::from_secs(60 * 60);
/// The `Services::print` hook.
pub type Hook = Box<dyn Fn(&[u8]) -> Result<(), String>>;
/// The desktop print hook; also clears print files left over from earlier runs.
pub fn hook() -> Hook {
let dir = std::env::temp_dir();
spawn_cleanup(dir.clone());
Box::new(move |bytes| {
let r = print_with(&dir, bytes, open_viewer);
spawn_cleanup(dir.clone());
r
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().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
let _ = sender.send(printers);
});
receiver
})
}
/// Writes `bytes` to a new print file in `dir` and has `open` show it. When `open` fails the file
/// is removed again.
fn print_with(dir: &Path, bytes: &[u8], open: impl Fn(&Path) -> Result<(), String>) -> Result<(), String> {
let path = write_temp(dir, bytes).map_err(|e| format!("couldn't write the PDF to print: {e}"))?;
open(&path).inspect_err(|_| {
let _ = std::fs::remove_file(&path);
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
// let process exit kill PowerShell/CUPS before it hands the job to the spooler.
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
pub fn finish_pending_jobs() {
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
for worker in workers {
if worker.join().is_err() {
log::error!("the print worker stopped unexpectedly during shutdown");
}
}
}
pub fn submit() -> Submit {
Box::new(|bytes, settings: Settings, collate| {
let (sender, receiver) = std::sync::mpsc::channel();
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
let job = craft_print::Job {
printer: Some(settings.printer),
copies: settings.copies.clamp(1, 999),
collate,
duplex: settings.duplex.map(|value| match value {
Duplex::LongEdge => craft_print::Duplex::LongEdge,
Duplex::ShortEdge => craft_print::Duplex::ShortEdge,
Duplex::Off => craft_print::Duplex::Off,
}),
grayscale: settings.grayscale,
scaling: craft_print::Scaling::ShrinkToFit,
title: "WordCraft document".into(),
..Default::default()
};
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
});
if let Ok(worker) = worker {
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
jobs.retain(|job| !job.is_finished());
jobs.push(worker);
}
receiver
})
}
/// A new file in `dir` with a unique print-file name, holding `bytes`. Never overwrites (or
/// follows a link at) an existing path; readable by the user only where the OS supports it.
fn write_temp(dir: &Path, bytes: &[u8]) -> io::Result<PathBuf> {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
for _ in 0..16 {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!("{PREFIX}{}-{stamp}-{n}.pdf", std::process::id()));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&path) {
Ok(mut file) => {
return match file.write_all(bytes) {
Ok(()) => Ok(path),
Err(e) => {
drop(file);
let _ = std::fs::remove_file(&path);
Err(e)
}
};
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(io::ErrorKind::AlreadyExists, "no free temporary file name"))
}
/// Removes print files in `dir` last written `keep` or longer before `now`; returns how many.
/// Other files, links and files it may not remove are left alone.
fn remove_stale(dir: &Path, now: SystemTime, keep: Duration) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut removed = 0;
for entry in entries.flatten() {
if !is_print_file(&entry.file_name().to_string_lossy()) {
continue;
}
// `DirEntry::metadata` doesn't follow links.
let Ok(meta) = entry.metadata() else { continue };
let old = meta.modified().ok().and_then(|m| now.duration_since(m).ok()).is_some_and(|age| age >= keep);
if meta.is_file() && old && std::fs::remove_file(entry.path()).is_ok() {
removed += 1;
}
}
removed
}
fn is_print_file(name: &str) -> bool {
name.starts_with(PREFIX) && name.ends_with(".pdf")
}
/// Runs [`remove_stale`] on a background thread (the temp folder can be large).
fn spawn_cleanup(dir: PathBuf) {
let spawned = std::thread::Builder::new().name("print-cleanup".into()).spawn(move || {
let n = remove_stale(&dir, SystemTime::now(), KEEP);
if n > 0 {
log::info!("removed {n} old print file(s)");
}
});
if let Err(e) = spawned {
log::warn!("print clean-up: {e}");
}
}
/// Opens the PDF in Preview without waiting for it; if Preview can't open it, the default app.
#[cfg(target_os = "macos")]
fn open_viewer(path: &Path) -> Result<(), String> {
let mut child =
std::process::Command::new("open").arg("-a").arg("Preview").arg(path).spawn().map_err(|e| format!("couldn't open Preview: {e}"))?;
let path = path.to_path_buf();
let waiter = std::thread::Builder::new().name("print-open".into()).spawn(move || {
if !child.wait().is_ok_and(|s| s.success())
&& let Err(e) = open::that(&path)
{
log::error!("print: couldn't open {}: {e}", path.display());
}
});
if let Err(e) = waiter {
log::warn!("print: {e}");
}
Ok(())
}
/// Opens the PDF in the default app without waiting for it (`xdg-open`, `gio`… on Linux and BSD;
/// the file association on Windows).
#[cfg(not(target_os = "macos"))]
fn open_viewer(path: &Path) -> Result<(), String> {
open::that_detached(path).map_err(|e| format!("couldn't open a PDF viewer: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::cell::RefCell;
use std::rc::Rc;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{Services, WordApp};
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("wordcraft-print-test-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// File › Print's Print button (`ui.print`) writes the document's PDF to a print file and
/// hands that file to the viewer (faked here: no process is started); a viewer that can't
/// start is reported in the status bar and leaves no file behind.
#[test]
fn file_print_opens_a_pdf_file() {
let dir = temp_dir("open");
let opened: Rc<RefCell<Vec<(PathBuf, Vec<u8>)>>> = Rc::default();
let seen = opened.clone();
let hook_dir = dir.clone();
let services = Services {
print: Some(Box::new(move |bytes| {
print_with(&hook_dir, bytes, |path| {
seen.borrow_mut().push((path.to_path_buf(), std::fs::read(path).map_err(|e| e.to_string())?));
Ok(())
})
})),
..Default::default()
};
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
app.run("text.insert", json!({"text": "Print me"})).unwrap();
app.run("file.print", json!({})).unwrap();
assert!(app.ui.backstage && app.ui.backstage_page == "print", "File › Print opens the Print page");
app.run("ui.print", json!({})).unwrap();
app.run("ui.print", json!({})).unwrap();
let opened = opened.borrow();
assert_eq!(opened.len(), 2, "each print opens the viewer once");
for (path, bytes) in opened.iter() {
assert!(bytes.starts_with(b"%PDF"), "the viewer gets a PDF");
assert_eq!(path.parent(), Some(dir.as_path()));
assert!(is_print_file(&path.file_name().unwrap().to_string_lossy()), "{}", path.display());
}
assert_ne!(opened[0].0, opened[1].0, "every print gets its own file");
let mut app = WordApp::new(
Session::new(wordcraft_doc::Document::new()),
Services {
print: Some(Box::new({
let dir = dir.clone();
move |bytes| print_with(&dir, bytes, |_| Err("no PDF viewer".into()))
})),
..Default::default()
},
);
let e = app.run("ui.print", json!({})).unwrap_err();
assert!(e.contains("no PDF viewer"), "{e}");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "the failed print's file is removed");
let _ = std::fs::remove_dir_all(&dir);
}
/// The clean-up removes old print files only.
#[test]
fn stale_print_files_are_removed() {
let dir = temp_dir("stale");
let fresh = write_temp(&dir, b"%PDF-1.7").unwrap();
let old = write_temp(&dir, b"%PDF-1.7").unwrap();
let hour_ago = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
OpenOptions::new().write(true).open(&old).unwrap().set_modified(hour_ago).unwrap();
let other = dir.join("notes.pdf");
std::fs::write(&other, b"keep").unwrap();
OpenOptions::new().write(true).open(&other).unwrap().set_modified(hour_ago).unwrap();
assert_eq!(remove_stale(&dir, SystemTime::now(), KEEP), 1);
assert!(fresh.exists() && other.exists() && !old.exists());
assert_eq!(remove_stale(&dir.join("missing"), SystemTime::now(), KEEP), 0, "a missing folder is fine");
let _ = std::fs::remove_dir_all(&dir);
fn shutdown_waits_for_pending_submission_workers() {
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();
});
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");
}
}
+1
View File
@@ -9,6 +9,7 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi
workspace = true
[dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
wordcraft-geom = { workspace = true }
wordcraft-doc = { workspace = true }
wordcraft-fonts = { workspace = true }
+13 -6
View File
@@ -2,7 +2,7 @@
//!
//! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`):
//! - `engine.execute {command, params}`: run any command (engine or `ui.*`)
//! - `engine.commands`: every command with its enablement
//! - `engine.commands {search?}`: engine and app print commands with enablement and parameters
//! - `document.inspect`: document structure; `ui.inspect`: UI state, canvas geometry, perf
//! - `ui.click {x, y, button?, count?, shift?, cmd?}`, `ui.move {x, y}`, `ui.drag {x, y, toX, toY}`:
//! real pointer input in screen points (reaches every widget)
@@ -119,12 +119,19 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O
}
"engine.commands" => {
let reg = app.session.registry.clone();
ok(Value::Array(
reg.all()
.iter()
let mut commands: Vec<Value> = reg.all().iter()
.map(|c| json!({"id": c.id, "label": c.label, "location": c.location, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(&app.session).is_none()}))
.collect(),
))
.collect();
commands.extend(crate::printing::commands(app));
let search = s("search").unwrap_or("").to_ascii_lowercase();
if !search.is_empty() {
commands.retain(|command| {
["id", "label", "location", "params"]
.iter()
.any(|field| command.get(field).and_then(Value::as_str).is_some_and(|text| text.to_ascii_lowercase().contains(&search)))
});
}
ok(Value::Array(commands))
}
"document.inspect" => match app.session.run("document.inspect", p) {
Ok(v) => ok(v),
+37 -38
View File
@@ -63,6 +63,7 @@ pub mod panes;
pub mod paste_picture;
pub mod previews;
pub mod print_page;
pub mod printing;
pub mod read_aloud;
pub mod ribbon;
pub mod scroll;
@@ -87,6 +88,10 @@ pub use file_dialogs::{FileDialogAnswer, FileDialogRequest, open_filters};
/// Platform services injected by the host (file dialogs, file I/O).
#[derive(Default)]
pub struct Services {
/// Desktop: discover configured printers on a worker.
pub printers: Option<printing::Discover>,
/// Desktop: submit the PDF with the chosen settings on a worker.
pub print_native: Option<printing::Submit>,
/// 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.
@@ -107,9 +112,8 @@ pub struct Services {
/// Web: told whether the document has unsaved changes after each pass, for the browser's
/// leave-page guard (`beforeunload` runs between frames and can't ask the app).
pub on_dirty: Option<Box<dyn Fn(bool)>>,
/// Hand PDF bytes to the system's print flow. Web: the browser's print dialog on that PDF (no
/// download, no intermediate file). Desktop: the PDF opens in the system's viewer (a temporary
/// file), where the user prints (#15). An error means nothing was opened.
/// Web: hand PDF bytes to the browser's print dialog (no download or intermediate file).
/// Desktop uses `print_native` instead. An error means nothing was opened.
pub print: Option<Box<dyn Fn(&[u8]) -> Result<(), String>>>,
/// Desktop: the picture on the system clipboard, if any — egui's paste only carries text
/// (#45). Read when Paste finds no text; see [`paste_picture`].
@@ -341,6 +345,8 @@ pub struct WordApp {
pub session: Session,
pub ui: UiState,
pub services: Services,
pub printing: printing::State,
print_feedback_seen: Option<printing::Feedback>,
pub canvas: canvas::CanvasState,
/// Content control frames, check boxes and lists on the page.
pub controls: content_controls::ControlUi,
@@ -460,6 +466,8 @@ impl WordApp {
session,
ui: UiState::default(),
services,
printing: Default::default(),
print_feedback_seen: None,
canvas: canvas::CanvasState::default(),
controls: Default::default(),
dialog: None,
@@ -828,6 +836,10 @@ impl WordApp {
/// The window's close button (or the system) asked to quit. Returns true to let the window
/// close; with unsaved changes it asks first and returns false.
pub fn close_requested(&mut self) -> bool {
if self.printing.busy() {
self.status(tl!("Wait for the print job to finish submitting before closing."));
return false;
}
if self.quit_requested || !self.session.dirty {
return true;
}
@@ -1088,10 +1100,9 @@ impl WordApp {
self.pick_recipient_list();
json!({"pending": self.recipient_list_pending})
}
// Send the document to the system print flow (web, desktop). `file.print` opens the Print
// page; this is the button on it, and the one programmatic call that opens system UI
// here, like `ui.openFileDialog` — only when the host can print.
"ui.print" => return Some(self.print_to_system(p)),
// 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)),
// 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
@@ -1139,38 +1150,9 @@ impl WordApp {
}))
}
/// `ui.print`: export the document to PDF in memory and hand it to the host's print hook.
/// `p` holds the print settings (`range`, `pages`, `parity`, `copies`, `collate`; see
/// `file.printPdf`); without any, every page prints once.
/// An error when the host can't print (File › Print then only saves a PDF).
/// Submit the selected print job; the implementation lives beside its localized controls.
fn print_to_system(&mut self, p: &Value) -> Result<Value, String> {
if self.services.print.is_none() {
return Err("printing to the system print dialog isn't available here; export a PDF instead".into());
}
// File › Options › Display: fields first, and the page colour only when asked for.
if self.ui.update_fields_before_print
&& let Err(e) = self.session.run("references.updateFields", &json!({}))
{
log::warn!("updating fields before printing: {e}");
}
let colour = if self.ui.print_backgrounds { None } else { self.session.doc.settings.page_color.take() };
let pdf = self.session.print_pdf(p).map_err(|e| e.to_string());
if colour.is_some() {
self.session.doc.settings.page_color = colour;
}
let result = pdf.and_then(|(bytes, _)| self.services.print.as_ref().map_or(Ok(()), |print| print(&bytes)).map(|()| bytes.len()));
match result {
Ok(len) => {
self.status(tl!("Opening print dialog…"));
Ok(json!({"printing": true, "bytes": len}))
}
Err(e) => {
log::error!("print failed: {e}");
let msg = i18n::fmt(tl!("Print failed: {error}"), &[("error", &e)]);
self.status(msg.clone());
Err(msg)
}
}
printing::submit(self, p)
}
/// File › Options › General › Make WordCraft the default…: hand over to the host's
@@ -1307,6 +1289,22 @@ impl WordApp {
}
}
/// Terminal feedback also reaches the normal window when printing starts through control.
pub fn poll_printing(&mut self, ctx: &egui::Context) {
self.printing.poll(ctx);
if self.printing.message == self.print_feedback_seen {
return;
}
self.print_feedback_seen = self.printing.message.clone();
let Some(message @ (printing::Feedback::Submitted(_) | printing::Feedback::Failed(_) | printing::Feedback::WorkerStopped)) =
self.printing.message.clone()
else {
return;
};
let text = printing::feedback(&message, |label| i18n::t(label).to_owned());
self.status(text);
}
/// Per-frame logic before layout: control requests, screenshots, shortcuts, file drops.
pub fn logic(&mut self, ctx: &egui::Context) {
let lang = i18n::Lang::from_pref(&self.ui.language);
@@ -1361,6 +1359,7 @@ impl WordApp {
if self.ctx.is_none() {
self.ctx = Some(ctx.clone());
}
self.poll_printing(ctx);
self.drain_control(ctx);
zotero::poll(self, ctx);
read_aloud::poll(self, ctx);
+24 -7
View File
@@ -1,9 +1,7 @@
//! File › Print (#641): the print settings and a preview of the pages.
//!
//! WordCraft prints through a PDF that the host opens in the system's print flow, so the settings
//! here are the ones that shape that PDF: which pages, odd/even, copies and collation. They go to
//! `ui.print` / `file.printPdf` as params (see `wordcraft_engine::print`). The printer, two-sided
//! printing and pages per sheet are chosen in the system print dialog that opens next.
//! Page range and parity shape the PDF. Native printer settings and copy collation go to
//! the shared spooler; browser printing and saved print PDFs retain copies in the PDF.
use std::sync::Arc;
@@ -109,8 +107,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
ui.vertical(|ui| {
ui.set_width(380.0);
ui.spacing_mut().item_spacing.y = 8.0;
let hook = app.services.print.is_some();
if hook {
let native = app.services.print_native.is_some();
let hook = native || app.services.print.is_some();
if native {
if let Some(discover) = &app.services.printers {
crate::printing::show(&mut app.printing, ui, discover, |label| crate::i18n::t(label).to_owned());
}
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."));
ui.label(
egui::RichText::new(tl!("The printer, two-sided printing and pages per sheet are chosen in that dialog.")).color(t.text_dim),
@@ -119,7 +123,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
ui.label(tl!("Save a copy in another format. Printing goes through a PDF you can print from any viewer."));
}
ui.add_space(4.0);
if hook && ui.add(egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() {
if hook
&& ui
.add_enabled(
!native || app.printing.ready(),
egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0)),
)
.clicked()
{
let params = app.ui.print.params();
let _ = app.run("ui.print", params);
}
@@ -131,6 +142,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
ui.add_space(6.0);
if native {
if app.ui.print.copies > 1 {
ui.add_enabled(!app.printing.busy(), egui::Checkbox::new(&mut app.ui.print.collate, tl!("Collate copies")));
}
} else {
ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0)));
ui.horizontal(|ui| {
let mut n = app.ui.print.copies;
@@ -140,6 +156,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
ui.checkbox(&mut app.ui.print.collate, tl!("Collate copies"));
}
});
}
ui.label(egui::RichText::new(tl!("Pages")).font(medium(13.0)));
if choice(ui, "print_range", &mut app.ui.print.range, &RANGES) {
+174
View File
@@ -0,0 +1,174 @@
//! Localized print-page wording around the shared, backend-independent controls.
use craft_ui::printing::Labels;
pub use craft_ui::printing::{Discover, Duplex, Feedback, Printer, Settings, State};
/// Native submission adds WordCraft's PDF-page collation setting to the shared spooler settings.
pub type Submit = Box<dyn Fn(Vec<u8>, Settings, bool) -> std::sync::mpsc::Receiver<Result<String, String>>>;
/// Metadata for the app-owned commands. Inspecting it never discovers printers or submits.
pub fn commands(app: &crate::WordApp) -> Vec<serde_json::Value> {
use serde_json::json;
let enabled = if app.services.print_native.is_some() { app.printing.ready() } else { app.services.print.is_some() };
let submit_params = "range?: all|current|selection|custom; pages?: string (1-3,5); parity?: all|odd|even; copies?: integer 1..999; collate?: bool. Native uses the selected printer, duplex and grayscale from ui.printSettings; omitted copies/collation use current settings. Browser printing opens its dialog. Submits only when explicitly executed.";
vec![
json!({"id":"ui.printSettings", "label":"Print settings", "location":"File › Print", "shortcut":null,
"params":"printer?: discovered name; copies?: integer 1..999; duplex?: printerDefault|off|longEdge|shortEdge|null; grayscale?: bool; refresh?: bool. Empty params inspect printer/copies/duplex/grayscale, printers, busy/discovering/ready and status/detail. Updates are atomic; refresh discovers only on native hosts. Never submits or opens a dialog.",
"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}),
]
}
pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate: impl Fn(&str) -> String) {
let labels = Labels {
printer: &translate("Printer"),
select_printer: &translate("Select a printer"),
refresh: &translate("Refresh printers"),
copies: &translate("Copies"),
duplex: &translate("Two-sided printing"),
printer_default: &translate("Printer default"),
one_sided: &translate("One-sided"),
long_edge: &translate("Two-sided: long edge"),
short_edge: &translate("Two-sided: short edge"),
grayscale: &translate("Grayscale"),
};
state.show(ui, egui::Id::new("wordcraft_print_settings"), discover, &labels);
if let Some(message) = &state.message {
ui.label(feedback(message, translate));
}
}
/// Read print settings/status, or validate an explicit update before changing anything.
/// `refresh: true` starts discovery only; this command never submits a job or opens a dialog.
pub fn settings(state: &mut State, params: &serde_json::Value, discover: Option<&Discover>) -> Result<serde_json::Value, String> {
use serde_json::{Value, json};
let object = params.as_object().ok_or("print settings must be an object")?;
if object.keys().any(|key| !["printer", "copies", "duplex", "grayscale", "refresh"].contains(&key.as_str())) {
return Err("unknown print setting; use printer, copies, duplex, grayscale or refresh".into());
}
if state.busy() && !object.is_empty() {
return Err("wait for the active print submission before changing settings".into());
}
let mut next = state.settings.clone();
if let Some(value) = object.get("printer") {
let printer = value.as_str().ok_or("printer must be a name from the discovered list")?;
if !state.printers.iter().any(|available| available.name == printer) {
return Err("printer must be a name from the discovered list".into());
}
next.printer = printer.to_owned();
}
if let Some(value) = object.get("copies") {
let copies = value.as_u64().filter(|copies| (1..=999).contains(copies)).ok_or("copies must be an integer from 1 to 999")?;
next.copies = copies as u32;
}
if let Some(value) = object.get("duplex") {
next.duplex = match value.as_str() {
Some("printerDefault") => None,
Some("off") => Some(Duplex::Off),
Some("longEdge") => Some(Duplex::LongEdge),
Some("shortEdge") => Some(Duplex::ShortEdge),
None if value.is_null() => None,
_ => return Err("duplex must be printerDefault, off, longEdge or shortEdge".into()),
};
}
if let Some(value) = object.get("grayscale") {
next.grayscale = value.as_bool().ok_or("grayscale must be a boolean")?;
}
let refresh = object.get("refresh").map(|value| value.as_bool().ok_or("refresh must be a boolean")).transpose()?.unwrap_or(false);
if refresh {
let discover = discover.ok_or("native printer discovery is unavailable here")?;
if !state.refresh(discover) {
return Err("printer discovery or submission is already active".into());
}
}
state.settings = next;
let (status, detail) = match state.message.as_ref() {
None => ("idle", ""),
Some(Feedback::FindingPrinters) => ("findingPrinters", ""),
Some(Feedback::NoPrinters) => ("noPrinters", ""),
Some(Feedback::DiscoveryFailed) => ("discoveryFailed", ""),
Some(Feedback::Submitting) => ("submitting", ""),
Some(Feedback::Submitted(receipt)) => ("submitted", receipt.as_str()),
Some(Feedback::Failed(error)) => ("failed", error.as_str()),
Some(Feedback::WorkerStopped) => ("workerStopped", ""),
};
Ok(json!({
"printer": state.settings.printer, "copies": state.settings.copies,
"duplex": match state.settings.duplex { None => "printerDefault", Some(Duplex::Off) => "off", Some(Duplex::LongEdge) => "longEdge", Some(Duplex::ShortEdge) => "shortEdge" },
"grayscale": state.settings.grayscale,
"printers": state.printers.iter().map(|printer| json!({"name": printer.name, "default": printer.default})).collect::<Vec<Value>>(),
"busy": state.busy(), "discovering": state.discovering(), "ready": state.ready(), "status": status, "detail": detail,
}))
}
pub fn feedback(message: &Feedback, translate: impl Fn(&str) -> String) -> String {
match message {
Feedback::FindingPrinters => translate("Finding printers…"),
Feedback::NoPrinters => translate("No printers found. Configure a system printer or export a PDF."),
Feedback::DiscoveryFailed => translate("Printer discovery failed."),
Feedback::Submitting => translate("Submitting print job…"),
Feedback::Submitted(receipt) if receipt.trim().is_empty() => translate("Print job submitted."),
Feedback::Submitted(receipt) => translate("Print job submitted: {receipt}").replace("{receipt}", receipt),
Feedback::Failed(error) => translate("Print failed: {error}").replace("{error}", error),
Feedback::WorkerStopped => translate("Print failed: the printing worker stopped."),
}
}
/// Build the upstream page-range/markup PDF and hand it to the chosen host print flow.
pub fn submit(app: &mut crate::WordApp, params: &serde_json::Value) -> Result<serde_json::Value, String> {
use serde_json::json;
if app.services.print.is_none() && app.services.print_native.is_none() {
return Err("printing to the system print dialog isn't available here; export a PDF instead".into());
}
let native = app.services.print_native.is_some();
if native && !app.printing.ready() {
return Err("Select an available printer in File > Print before submitting.".into());
}
let mut pdf_params = params.clone();
let mut settings = app.printing.settings.clone();
let collate = params.get("collate").map(|value| value.as_bool().ok_or("collate must be a boolean")).transpose()?.unwrap_or(app.ui.print.collate);
if native {
let object = pdf_params.as_object_mut().ok_or("print settings must be an object")?;
if let Some(copies) = object.get("copies") {
settings.copies = copies.as_u64().filter(|copies| (1..=999).contains(copies)).ok_or("copies must be an integer from 1 to 999")? as u32;
}
// Copies/collation belong to the spooler. Baking them into the PDF would multiply
// the copies twice, and duplex could put the end of one copy behind the next.
object.insert("copies".into(), json!(1));
}
if app.ui.update_fields_before_print
&& let Err(error) = app.session.run("references.updateFields", &json!({}))
{
log::warn!("updating fields before printing: {error}");
}
let colour = if app.ui.print_backgrounds { None } else { app.session.doc.settings.page_color.take() };
let pdf = app.session.print_pdf(&pdf_params).map_err(|error| error.to_string());
if colour.is_some() {
app.session.doc.settings.page_color = colour;
}
let result = pdf.and_then(|(bytes, plan)| {
if native && plan.chosen.checked_mul(settings.copies as usize).is_none_or(|pages| pages > wordcraft_engine::print::MAX_PRINT_PAGES) {
return Err("the selected pages and copies exceed the print-job page limit".into());
}
let len = bytes.len();
if let Some(submit) = &app.services.print_native {
app.printing.begin(submit(bytes, settings, collate)).map_err(|_| "A print job is already being submitted.".to_owned())?;
app.print_feedback_seen = None;
} else if let Some(print) = &app.services.print {
print(&bytes)?;
}
Ok(len)
});
match result {
Ok(len) => {
app.status(if native { tl!("Submitting print job…") } else { tl!("Opening print dialog…") });
Ok(json!({"printing": true, "bytes": len, "pending": native}))
}
Err(error) => {
log::error!("print failed: {error}");
let msg = crate::i18n::fmt(tl!("Print failed: {error}"), &[("error", &error)]);
app.status(msg.clone());
Err(msg)
}
}
}
+203
View File
@@ -0,0 +1,203 @@
//! Native print onboarding preserves engine PDF generation and submits the selected settings.
use serde_json::json;
use std::{cell::RefCell, rc::Rc};
use wordcraft_engine::Session;
use wordcraft_ui_egui::printing::{Duplex, Feedback, Printer};
use wordcraft_ui_egui::{Services, WordApp};
#[test]
fn control_print_discovery_reports_metadata_without_host_side_effects() {
use std::cell::Cell;
use wordcraft_ui_egui::control::{ControlRequest, Outcome, handle};
let discoveries = Rc::new(Cell::new(0));
let submissions = Rc::new(Cell::new(0));
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
let observed = discoveries.clone();
app.services.printers = Some(Box::new(move || {
observed.set(observed.get() + 1);
let (_, receiver) = std::sync::mpsc::channel();
receiver
}));
let observed = submissions.clone();
app.services.print_native = Some(Box::new(move |_, _, _| {
observed.set(observed.get() + 1);
let (_, receiver) = std::sync::mpsc::channel();
receiver
}));
let ctx = egui::Context::default();
let (request, _) = ControlRequest::new("engine.commands", json!({"search":"print"}));
let Outcome::Done(reply) = handle(&mut app, &ctx, &request) else { panic!("metadata replies immediately") };
assert_eq!(reply["ok"], true);
let commands = reply["result"].as_array().unwrap();
assert!(commands.iter().any(|command| command["id"] == "file.print"), "engine print commands remain visible");
let settings = commands.iter().find(|command| command["id"] == "ui.printSettings").unwrap();
assert_eq!(settings["enabled"], true, "settings/status inspection is available");
for field in ["printer", "copies", "duplex", "grayscale", "refresh", "status", "detail"] {
assert!(settings["params"].as_str().unwrap().contains(field), "documented setting: {field}");
}
for id in ["ui.printSubmit", "ui.print"] {
let command = commands.iter().find(|command| command["id"] == id).unwrap();
assert_eq!(command["enabled"], false, "a printer must be selected before submission");
for field in ["range", "pages", "parity", "copies", "collate"] {
assert!(command["params"].as_str().unwrap().contains(field), "{id}: {field}");
}
}
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap();
let Outcome::Done(reply) = handle(&mut app, &ctx, &request) else { panic!("metadata replies immediately") };
for id in ["ui.printSubmit", "ui.print"] {
let command = reply["result"].as_array().unwrap().iter().find(|command| command["id"] == id).unwrap();
assert_eq!(command["enabled"], true, "the selected queue enables {id}");
}
assert_eq!(discoveries.get(), 0, "metadata never starts printer discovery");
assert_eq!(submissions.get(), 0, "metadata never submits");
assert!(app.dialog.is_none() && !app.ui.backstage, "metadata never opens a dialog or Print page");
assert!(!app.printing.busy() && !app.session.dirty);
}
#[test]
fn native_print_uses_selected_settings_and_reports_spool_errors() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
app.run("text.insert", json!({"text": "A document to print"})).unwrap();
let jobs = Rc::new(RefCell::new(Vec::new()));
let observed = jobs.clone();
app.services.print_native = Some(Box::new(move |bytes, settings, collate| {
observed.borrow_mut().push((bytes, settings, collate));
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Err("printer is offline".into())).unwrap();
receiver
}));
assert!(app.run("ui.print", json!({})).is_err());
assert!(jobs.borrow().is_empty(), "opening settings/exporting does not spool");
app.printing.printers.push(Printer { name: "Chosen queue".into(), default: false });
let configured = app.run("ui.printSettings", json!({"printer":"Chosen queue", "copies":3,"duplex":"shortEdge","grayscale":true})).unwrap();
assert_eq!(configured["ready"], true);
assert_eq!(configured["printers"][0]["name"], "Chosen queue");
app.run("ui.print", json!({})).unwrap();
assert!(app.printing.busy());
assert!(!app.close_requested(), "a pending print job cannot be interrupted by normal close");
let jobs = jobs.borrow();
assert_eq!(jobs.len(), 1);
assert!(jobs[0].0.starts_with(b"%PDF"));
assert_eq!(jobs[0].1.printer, "Chosen queue");
assert_eq!(jobs[0].1.copies, 3);
assert_eq!(jobs[0].1.duplex, Some(Duplex::ShortEdge));
assert!(jobs[0].1.grayscale);
assert!(jobs[0].2, "copies are collated by default");
app.poll_printing(&egui::Context::default());
assert_eq!(app.printing.message, Some(Feedback::Failed("printer is offline".into())));
assert!(app.status_msg.as_ref().unwrap().0.contains("printer is offline"));
}
#[test]
fn print_settings_are_visible_in_a_narrow_window() {
use egui_kittest::kittest::{NodeT, Queryable};
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
app.run("text.insert", json!({"text": "A document to print"})).unwrap();
app.services.printers = Some(Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(vec![Printer { name: "Test queue".into(), default: true }]).unwrap();
receiver
}));
app.services.print_native = Some(Box::new(|_, _, _| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Ok("queued".into())).unwrap();
receiver
}));
app.ui.backstage = true;
app.ui.backstage_page = "print".into();
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 700.0)).build_ui_state(
|ui, app: &mut WordApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
harness.run_steps(5);
assert!(!harness.get_by_label("Refresh printers").accesskit_node().is_disabled());
assert!(!harness.get_by_label("Grayscale").accesskit_node().is_disabled());
harness.get_by_label("Grayscale").click();
harness.run_steps(2);
assert!(harness.state().printing.settings.grayscale);
assert!(harness.state().printing.ready());
}
#[test]
fn invalid_print_settings_are_rejected_without_partial_changes() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
let before = app.run("ui.printSettings", json!({})).unwrap();
for params in [
json!({"printer":"Missing"}),
json!({"copies":0}),
json!({"copies":1000}),
json!({"copies":1.5}),
json!({"copies":-1}),
json!({"copies":"3"}),
json!({"duplex":"maybe"}),
json!({"grayscale":"yes"}),
json!({"refresh":"yes"}),
json!({"copies":3,"duplex":"invalid"}),
json!([]),
] {
assert!(app.run("ui.printSettings", params.clone()).is_err(), "{params}");
assert_eq!(app.run("ui.printSettings", json!({})).unwrap(), before, "no partial update: {params}");
}
}
#[test]
fn repeated_fast_jobs_each_surface_an_identical_receipt() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap();
app.services.print_native = Some(Box::new(|_, _, _| {
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Ok("same receipt".into())).unwrap();
receiver
}));
for _ in 0..2 {
app.status_msg = None;
app.run("ui.printSubmit", json!({})).unwrap();
// Completion may be consumed by the shared widget before the app's logic pass.
app.printing.poll(&egui::Context::default());
app.poll_printing(&egui::Context::default());
assert!(app.status_msg.as_ref().unwrap().0.contains("same receipt"));
app.status_msg = None;
app.poll_printing(&egui::Context::default());
assert!(app.status_msg.is_none());
}
}
#[test]
fn native_ranges_and_parity_do_not_duplicate_spooler_copies() {
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
for page in 1..=3 {
app.run("text.insert", json!({"text":format!("Distinct page {page}")})).unwrap();
if page < 3 {
app.run("insert.pageBreak", json!({})).unwrap();
}
}
assert_eq!(app.session.export_layout().pages.len(), 3);
app.printing.printers.push(Printer { name: "Queue".into(), default: true });
app.run("ui.printSettings", json!({"printer":"Queue","copies":3})).unwrap();
let jobs = Rc::new(RefCell::new(Vec::new()));
let observed = jobs.clone();
app.services.print_native = Some(Box::new(move |bytes, settings, collate| {
observed.borrow_mut().push((bytes, settings, collate));
let (sender, receiver) = std::sync::mpsc::channel();
sender.send(Ok("queued".into())).unwrap();
receiver
}));
for bad in [json!({"pages":"8"}), json!({"parity":"bad"}), json!({"copies":0}), json!({"collate":"yes"})] {
assert!(app.run("ui.printSubmit", bad).is_err());
}
assert!(jobs.borrow().is_empty(), "invalid settings do not send any job");
let params = json!({"pages":"1-3","parity":"odd","collate":false});
let (expected, plan) = app.session.print_pdf(&params).unwrap();
assert_eq!(plan.pages, vec![0, 2]);
app.run("ui.printSubmit", params).unwrap();
let jobs = jobs.borrow();
assert_eq!(jobs[0].0, expected, "the PDF contains only the two selected pages, once each");
assert_eq!(jobs[0].1.copies, 3, "copies go to the spooler, not the PDF");
assert!(!jobs[0].2, "uncollated copies reach the spooler");
}
+24
View File
@@ -69,3 +69,27 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
Any other method name is treated as a command id: `{"method": "format.bold"}` works.
Positions are `{"story": "body" | {"part": n}, "path": [block, row, cell, block, …], "off": byteOffset}`.
### Native printing
`engine.execute` can call `ui.printSettings` with `{}` to inspect the printer list, selected
printer, copies, duplex, grayscale, discovery/submission status and backend receipt/error.
`{"refresh":true}` starts background discovery; poll the same command until `discovering` is
false. Set `printer` to a discovered name, integer `copies` from 1 to 999, `duplex` to
`printerDefault`, `off`, `longEdge` or `shortEdge`, and boolean `grayscale`. Invalid updates are
rejected without changing earlier settings. `ui.printSubmit` explicitly submits the current
print-ready PDF without opening a dialog; inspect `ui.printSettings` until `busy` becomes false
and check `status`/`detail`. The browser keeps its own print hook. PDF export remains separate.
`ui.printSubmit` / `ui.print` also accept the upstream PDF page settings (`range`, `pages`,
`parity`, `copies`, `collate`). Native printing renders the selected pages once and passes
copies/collation to the spooler; the browser and `file.printPdf` keep copies in the PDF.
Omitting native `copies` uses the configured `ui.printSettings` value. `collate` defaults to
File › Print's setting; invalid ranges submit no job.
`engine.commands {"search":"print"}` lists the engine print commands and the app-owned
`ui.printSettings`, `ui.printSubmit`, and legacy `ui.print` alias, with current enablement
and their parameter documentation. Search is case-insensitive across IDs, labels, locations
and parameter descriptions. Listing commands never discovers printers, submits a job or opens
a dialog. Settings inspection is always available; submission becomes enabled when the native
printer is ready, or when the browser host provides its print flow.
+4 -1
View File
@@ -2,6 +2,8 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
wall-clock estimate, and which parity document tracks it. Agents: pick from the top, check open PRs
@@ -18,7 +20,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82); memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
| 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); File › Print has a preview, page range, odd/even, copies and collate (#641); printer choice, duplex and pages per sheet are still the system's; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
| 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
| 7 | **Dialog depth**: 54 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) |
| 8 | **Proofing covers English, Polish and Portuguese only**; no dictionaries, grammar or hyphenation for the other ~47 languages Word proofs (text in them is left unmarked) | | `crates/proof`: English, Polish and Portuguese (pt-BR via the installed VERO dictionary, 2026-10-12), a language per run (2026-10-10); Word ships 120 proofing tools; issues #25, #40, #100 | Every writer outside English, Polish and Portuguese | 20–40 | [localization-parity.md](localization-parity.md) |
| 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) |
@@ -86,6 +88,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| 2026-10-11 | trivial | UI/UX gaps list: the system's animation setting is now read on macOS and the Linux/BSD desktops; trying it on real machines remains |
| 2026-10-11 | trivial | Animation effects setting landed (File › Options › Accessibility); UI/UX gaps list: the system's animation setting isn't read on macOS or Linux/BSD yet |
| 2026-10-11 | trivial | Word XML Document (Flat OPC) and Word 2003 XML opening landed (#548); gap #26 narrowed to 15–28 h |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | trivial | #13 narrowed: Insert › SmartArt with eight layouts, Text Pane and SmartArt Design landed (#497); 25–40 h left for charts |
| 2026-10-11 | trivial | File › Options panes landed (#485); removed from the dialog-depth list |
| 2026-10-11 | trivial | #18: the document canvas describes its text, structure and caret to screen readers through AccessKit (#488); real screen-reader testing remains |
+4 -1
View File
@@ -2,6 +2,8 @@
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** 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.
Word is not a hardware-heavy app, but a few devices matter to its users: printers above all, then
pens, microphones (Dictate) and speakers (Read Aloud), and displays.
@@ -10,7 +12,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays.
| Hardware | Word | WordCraft macOS | Windows | Linux / BSD | Web | Parity | Hours |
|---|---|---|---|---|---|---|---|
| Printers: native print dialog, printer choice, copies, duplex, page ranges, tray, scaling | ✅ (`WordPDE.plugin` print-dialog extension on Mac) | 🟡 PDF opens in Preview to print (#286) | 🟡 PDF opens in the default viewer (#286) | 🟡 PDF opens in the default viewer (#286) | ✅ browser print dialog (#209) | 35% | 5–8 |
| Printers: native print dialog, printer choice, copies, duplex, page ranges, tray, scaling | ✅ (`WordPDE.plugin` print-dialog extension on Mac) | 🟡 Direct CUPS printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | ✅ browser print dialog (#209) | 35% | 5–8 |
| GPU rendering, HiDPI/Retina, pixel-aligned text | ✅ | ✅ wgpu + vello_cpu rasteriser, whole-pixel text (#110, #165) | ✅ DirectX 12 default (#50), OpenGL when DirectX 12 has no GPU driver (#316) or on 32-bit; startup crash on Intel UHD (#170) | ✅ (soft text on KDE at some zooms, #140) | ✅ | 80% | 3–5 (bugs) |
| Multiple monitors, per-monitor DPI, remembered window position | ✅ | ✅ (#38, #76) | ✅ (winit DPI fix pending, #155) | ✅ | — | 85% | 1 |
| Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | ❌ | ❌ | ❌ | ❌ | 0% | (Draw tab, 15–25) |
@@ -24,6 +26,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | 32-bit Windows starts on OpenGL: DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. The upstream preview, page ranges, parity and Print Markup remain available. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. |
| 2026-10-10 | trivial | Windows falls back to OpenGL when DirectX 12 offers only the software adapter (#316) |
| 2026-10-10 | trivial | Desktop File › Print opens the PDF in the system viewer (#286) |
| 2026-10-10 | trivial | Inertial touchpad scrolling landed (#252) |