From 98c457a98d884b3df4e9ee7c9b0cf6a259b297e0 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 03:35:23 +0200 Subject: [PATCH 1/2] Print: adopt craft-print with native settings and CI --- .github/workflows/printing.yml | 73 +++++++ Cargo.lock | 19 +- ROADMAP.md | 4 +- apps/wordcraft/Cargo.toml | 4 +- apps/wordcraft/src/main.rs | 8 +- apps/wordcraft/src/print.rs | 278 ++++++--------------------- crates/docx/src/read/story.rs | 9 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/backstage.rs | 16 +- crates/ui-egui/src/lib.rs | 66 +++++-- crates/ui-egui/src/printing.rs | 98 ++++++++++ crates/ui-egui/tests/native_print.rs | 118 ++++++++++++ docs/control-protocol.md | 11 ++ docs/gaps.md | 5 +- docs/hardware-parity.md | 5 +- 15 files changed, 468 insertions(+), 247 deletions(-) create mode 100644 .github/workflows/printing.yml create mode 100644 crates/ui-egui/src/printing.rs create mode 100644 crates/ui-egui/tests/native_print.rs diff --git a/.github/workflows/printing.yml b/.github/workflows/printing.yml new file mode 100644 index 0000000..bd0e2dc --- /dev/null +++ b/.github/workflows/printing.yml @@ -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] + 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 diff --git a/Cargo.lock b/Cargo.lock index 32c36a9..227247a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -842,6 +842,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" @@ -5001,12 +5017,12 @@ name = "wordcraft" version = "0.5.0" dependencies = [ "arboard", + "craft-print", "eframe", "egui", "fmv-macos-events", "getrandom 0.3.4", "log", - "open", "pollster 0.4.0", "rfd", "serde_json", @@ -5223,6 +5239,7 @@ dependencies = [ name = "wordcraft-ui-egui" version = "0.5.0" dependencies = [ + "craft-ui", "egui", "egui_kittest", "image", diff --git a/ROADMAP.md b/ROADMAP.md index 47be1a0..91518a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); previously trivial: Turkish interface landed; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 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 @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 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 | Insert › SmartArt (#497): list, process, cycle, hierarchy, pyramid, radial, matrix and Venn graphics laid out by our own code, Text Pane, SmartArt Design tab (Add Shape, Promote, Demote, layouts, colours, Reset); saved as DOCX diagram parts with the drawing. Catalog 410/441 (93.0%) | | 2026-10-11 | Outline view (#493): Outlining tab, outline symbols and indents, collapse/expand, Show Level, first line only, plain text; catalog 428/450 → 441/463 (95.2%) | | 2026-10-11 | Vertical sections (#491): Layout › Text Direction turns the section (or the cell or text box the caret is in); lines run top to bottom, right to left, ideographs upright; `w:textDirection` in `w:sectPr` read and written | @@ -177,6 +178,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 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. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 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) | | 2026-10-11 | trivial | Progress log: vertical sections (#491) | diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 141b569..8976b6d 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -33,6 +33,7 @@ winresource = "0.1" fmv-macos-events = "=0.1.0" [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. @@ -42,6 +43,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" diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index d9fed95..f13c577 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -51,7 +51,7 @@ impl eframe::App for App { if ctx.input(|i| i.viewport().close_requested()) && !self.0.close_requested() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } - if self.0.quit_requested { + if self.0.quit_requested && !self.0.printing.busy() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } @@ -63,6 +63,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(); @@ -121,11 +122,12 @@ 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), and pictures on the system clipboard (#45). +/// through the system spooler (`print`), and pictures on the system clipboard (#45). fn services(file_dialog: file_dialogs::Hook) -> 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)), ..Default::default() } diff --git a/apps/wordcraft/src/print.rs b/apps/wordcraft/src/print.rs index 826574f..fdcc87d 100644 --- a/apps/wordcraft/src/print.rs +++ b/apps/wordcraft/src/print.rs @@ -1,229 +1,75 @@ -//! 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 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>> = 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| { + 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), + 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 { - 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); + 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"); } } diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 4ff2ff4..e67d413 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -43,7 +43,8 @@ enum Phase { enum Item { Text(String), - Obj(InlineObject), + // Field results mostly contain text; a picture/shape must not enlarge every buffer item. + Obj(Box), } struct FieldState { @@ -787,7 +788,7 @@ impl Reader<'_> { for (it, p) in f.buf { match it { Item::Text(t) => self.emit_text(sc, pb, &t, &p), - Item::Obj(o) => self.emit_obj(sc, pb, o, &p), + Item::Obj(o) => self.emit_obj(sc, pb, *o, &p), } } self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props); @@ -828,7 +829,7 @@ impl Reader<'_> { for (it, p) in f.buf.drain(..) { match it { Item::Text(t) => pb.push_text(&t, &p), - Item::Obj(o) => pb.push_obj(o, &p), + Item::Obj(o) => pb.push_obj(*o, &p), } } } @@ -849,7 +850,7 @@ impl Reader<'_> { match sink(sc) { Some(f) => { if f.phase == Phase::Result && f.buf.len() < 100_000 { - f.buf.push((Item::Obj(o), props.clone())); + f.buf.push((Item::Obj(Box::new(o)), props.clone())); } } _ => pb.push_obj(o, props), diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 54924d0..09746f7 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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 } diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index dc30262..cdcd866 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -333,10 +333,20 @@ fn info_page(app: &mut WordApp, ui: &mut Ui) { fn export_page(app: &mut WordApp, ui: &mut Ui) { let is_print = app.ui.backstage_page == "print"; heading(ui, if is_print { "Print" } else { "Export" }); - if is_print && app.services.print.is_some() { - ui.label(tl!("Send the document straight to the system print dialog, or save a copy in another format below.")); + if is_print && (app.services.print.is_some() || app.services.print_native.is_some()) { + if let Some(discover) = &app.services.printers { + crate::printing::show(&mut app.printing, ui, discover, |label| crate::i18n::t(label).to_owned()); + } else { + ui.label(tl!("Send the document straight to the system print dialog, or save a copy in another format below.")); + } ui.add_space(12.0); - if ui.add(egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() { + if ui + .add_enabled( + app.services.print_native.is_none() || app.printing.ready(), + egui::Button::new(egui::RichText::new(tl!("Print")).font(medium(13.5))).min_size(vec2(320.0, 34.0)), + ) + .clicked() + { let _ = app.run("ui.print", json!({})); } ui.add_space(12.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7a24d86..906ffa4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -49,6 +49,7 @@ pub mod options; pub mod panes; pub mod paste_picture; pub mod previews; +pub mod printing; pub mod read_aloud; pub mod ribbon; pub mod scroll; @@ -69,6 +70,10 @@ pub use file_dialogs::FileDialogRequest; /// 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, + /// Desktop: submit the PDF with the chosen settings on a worker. + pub print_native: Option, /// Pick a file to open; the argument says what for (`document`, `picture`). pub pick_open: Option Option>>, /// Pick a path to save to, given a suggested name. @@ -88,9 +93,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>, - /// 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 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`]. @@ -234,6 +238,8 @@ pub struct WordApp { pub session: Session, pub ui: UiState, pub services: Services, + pub printing: printing::State, + print_feedback_seen: Option, pub canvas: canvas::CanvasState, /// Content control frames, check boxes and lists on the page. pub controls: content_controls::ControlUi, @@ -329,6 +335,8 @@ impl WordApp { session, ui: UiState::default(), services, + printing: Default::default(), + print_feedback_seen: None, canvas: canvas::CanvasState::default(), controls: Default::default(), dialog: None, @@ -622,6 +630,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; } @@ -792,10 +804,10 @@ 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()), + // Explicit submission: the browser owns its dialog; native submits the selected + // job on a worker. `file.print` opens the settings page instead. + "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()), "ui.discord" => { self.canvas.open_url = Some("https://discord.gg/artcraft".into()); json!({}) @@ -816,9 +828,12 @@ 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 (File › Print then only saves a PDF). fn print_to_system(&mut self) -> Result { - if self.services.print.is_none() { + if self.services.print.is_none() && self.services.print_native.is_none() { return Err("printing to the system print dialog isn't available here; export a PDF instead".into()); } + if self.services.print_native.is_some() && !self.printing.ready() { + return Err("Select an available printer in File > Print before submitting.".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!({})) @@ -830,12 +845,22 @@ impl WordApp { d.settings.page_color = None; d }); - let result = wordcraft_engine::io::save_bytes("document.pdf", without_colour.as_ref().unwrap_or(&self.session.doc)) - .and_then(|bytes| self.services.print.as_ref().map_or(Ok(()), |print| print(&bytes)).map(|()| bytes.len())); + let result = wordcraft_engine::io::save_bytes("document.pdf", without_colour.as_ref().unwrap_or(&self.session.doc)).and_then(|bytes| { + let len = bytes.len(); + if let Some(submit) = &self.services.print_native { + self.printing + .begin(submit(bytes, self.printing.settings.clone())) + .map_err(|_| "A print job is already being submitted.".to_owned())?; + self.print_feedback_seen = None; + Ok(len) + } else { + self.services.print.as_ref().map_or(Ok(()), |print| print(&bytes)).map(|()| len) + } + }); match result { Ok(len) => { - self.status(tl!("Opening print dialog…")); - Ok(json!({"printing": true, "bytes": len})) + self.status(if self.services.print_native.is_some() { tl!("Submitting print job…") } else { tl!("Opening print dialog…") }); + Ok(json!({"printing": true, "bytes": len, "pending": self.services.print_native.is_some()})) } Err(e) => { log::error!("print failed: {e}"); @@ -935,6 +960,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); @@ -970,6 +1011,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); diff --git a/crates/ui-egui/src/printing.rs b/crates/ui-egui/src/printing.rs new file mode 100644 index 0000000..7c1cde8 --- /dev/null +++ b/crates/ui-egui/src/printing.rs @@ -0,0 +1,98 @@ +//! 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, Submit}; + +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 { + 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::>(), + "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."), + } +} diff --git a/crates/ui-egui/tests/native_print.rs b/crates/ui-egui/tests/native_print.rs new file mode 100644 index 0000000..590dde0 --- /dev/null +++ b/crates/ui-egui/tests/native_print.rs @@ -0,0 +1,118 @@ +//! 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 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| { + observed.borrow_mut().push((bytes, settings)); + 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); + 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()); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ff88f14..1d19bb3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -68,3 +68,14 @@ 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. diff --git a/docs/gaps.md b/docs/gaps.md index 49e9414..d2a6cf4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Insert › SmartArt landed, #497; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 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 @@ -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) (lag on GPUs without a DirectX 12 driver fixed, #316), 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; 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 | 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); 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) | +| 6 | **Print dialog depth**: desktop printer choice, copies, duplex and grayscale now submit through craft-print; page ranges and driver tray/options UI 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 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-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. Remaining print parity includes page ranges and driver-specific tray/options 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 | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index ebcfb96..17b8b05 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows 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 +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) | 🟡 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, duplex and grayscale | 🟡 Direct spooler printing; printer, copies, duplex and grayscale | 🟡 Direct spooler printing; printer, copies, duplex 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); 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,6 +23,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays. | Date | Change | Summary | |---|---|---| +| 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. Remaining print parity includes page ranges and driver-specific tray/options 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) | From 36af036233c31c6685af65da451dc6a3becbd923 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 17:10:30 +0200 Subject: [PATCH 2/2] PRINT: scope XDG absolute-path regression to Unix --- apps/wordcraft/src/default_apps.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/wordcraft/src/default_apps.rs b/apps/wordcraft/src/default_apps.rs index 8f07b21..263fc73 100644 --- a/apps/wordcraft/src/default_apps.rs +++ b/apps/wordcraft/src/default_apps.rs @@ -61,7 +61,7 @@ fn xdg_mime_args(desktop_id: &str) -> Vec { /// 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 { 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);