diff --git a/Cargo.lock b/Cargo.lock index fff9355..31c3f96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4870,6 +4870,7 @@ dependencies = [ "fmv-macos-events", "getrandom 0.3.4", "log", + "open", "pollster 0.4.0", "rfd", "serde_json", diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index f006c2b..05fe886 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -39,3 +39,6 @@ rfd = { workspace = true } pollster = "0.4" # 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" diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 2a0991f..d0b1291 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -16,6 +16,7 @@ mod file_dialogs; #[cfg(any(target_os = "windows", test))] mod graphics; mod logging; +mod print; use wordcraft_engine::Session; use wordcraft_ui_egui::{ @@ -118,9 +119,10 @@ fn save_prefs(app: &WordApp) { } } -/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94). +/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), and File › Print +/// through the system's PDF viewer (`print`, #15). fn services(file_dialog: file_dialogs::Hook) -> Services { - Services { file_dialog: Some(file_dialog), ..Default::default() } + Services { file_dialog: Some(file_dialog), print: Some(print::hook()), ..Default::default() } } /// Window, Dock and taskbar icon. diff --git a/apps/wordcraft/src/print.rs b/apps/wordcraft/src/print.rs new file mode 100644 index 0000000..826574f --- /dev/null +++ b/apps/wordcraft/src/print.rs @@ -0,0 +1,229 @@ +//! 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 + +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 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 + }) +} + +/// 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); + }) +} + +/// 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 { + 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)>>> = 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); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 181dcae..313cd83 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -67,8 +67,9 @@ 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>, - /// Web: hand PDF bytes to the browser's print flow directly (no download, no intermediate - /// file) — opens the system print dialog on that PDF. An error means no dialog was opened. + /// 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. pub print: Option Result<(), String>>>, } @@ -591,7 +592,7 @@ impl WordApp { self.pick_recipient_list(); json!({"pending": self.recipient_list_pending}) } - // Send the document to the system print dialog (web). `file.print` opens the Print + // 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()), @@ -604,7 +605,7 @@ impl WordApp { } /// `ui.print`: export the document to PDF in memory and hand it to the host's print hook. - /// An error when the host can't print (desktop: File › Print saves a PDF instead). + /// An error when the host can't print (File › Print then only saves a PDF). fn print_to_system(&mut self) -> Result { if self.services.print.is_none() { return Err("printing to the system print dialog isn't available here; export a PDF instead".into()); @@ -1411,7 +1412,7 @@ mod tests { } /// `ui.print` hands the document's PDF to the host's print hook, and is an error (with - /// nothing printed) when the host has none, as on desktop. + /// nothing printed) when the host has none. #[test] fn ui_print_needs_the_hosts_print_hook() { let mut a = typed(); diff --git a/docs/gaps.md b/docs/gaps.md index 1726a27..48f3209 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -16,7 +16,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170), 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) | 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; columns don't balance; long footnotes don't continue; 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 | **Formatting revisions, move tracking and content controls** are lost (`w:rPrChange`/`w:pPrChange` absent; `w:sdt` unwrapped) | B | Issue #41; source grep | Legal and editorial review workflows; templates with form controls | 12–18 | [file-format-parity.md](file-format-parity.md) | -| 6 | **No native printing** on desktop (PDF export only); web prints through the browser (#209) | B | `file.print` exports PDF; issue #15 | Everyone who prints | 6–10 | [hardware-parity.md](hardware-parity.md) | +| 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); no print dialog of our own (printer, copies, ranges); 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**: 16 modal dialogs vs Word's ~100 (Tabs, Borders and Shading, Columns, Symbol, Field, Define Multilevel List, Table Properties, Track Changes Options, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) | | 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [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) | @@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | #6: desktop File › Print opens the system PDF viewer (#286) | | 2026-10-10 | trivial | Merged main: landed features removed from the feature-gap list | | 2026-10-10 | minor | Beta note restated: full number back to the additive ~60% (method aligned with the standard, no new evidence) | | 2026-10-10 | major | First version, from the full re-measure: 29 ranked gaps with evidence, impact and hours | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index adeb2cc..f9a3934 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -10,7 +10,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) | ❌ via PDF export | ❌ via PDF | ❌ via PDF | ✅ browser print dialog (#209) | 25% | 6–10 | +| 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 | | GPU rendering, HiDPI/Retina, pixel-aligned text | ✅ | ✅ wgpu + vello_cpu rasteriser, whole-pixel text (#110, #165) | ✅ DirectX 12 default (#50); 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) | @@ -23,5 +23,6 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays. | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | Desktop File › Print opens the PDF in the system viewer (#286) | | 2026-10-10 | trivial | Inertial touchpad scrolling landed (#252) | | 2026-10-10 | major | First version |