diff --git a/ROADMAP.md b/ROADMAP.md index a9936925..eafba6fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (print-preview tests use egui's emitted URL, and macOS removes its previews before exit; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -146,6 +146,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition | | 2026-10-27 | Set Up Slide Show ▸ Keep mouse cursor visible: a full-screen show no longer hides a still pointer, so the choices of a branching deck stay visible and clickable; the setting is kept with the user's preferences (`ui.json`, `showKeepCursor`), off by default (#1445) | | 2026-10-25 | A photo in a picture placeholder is cut into the shape its layout gives the placeholder, as PowerPoint draws it: a layout's round picture placeholder keeps a slide's photo in a circle with the layout's white border, where it came out as a rectangle (the reader only passed `p:sp` placeholders to `inherit_placeholder_geometry`, not `p:pic`) | diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index 03859dc0..ad3f820f 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -60,6 +60,8 @@ impl eframe::App for App { // nothing after it is needed: the preferences are saved and the log file is unbuffered. // `on_exit` only runs once the app exists, never on a failed start. Same as GridCraft #117. #[cfg(target_os = "macos")] + self.0.cleanup_print_previews(); + #[cfg(target_os = "macos")] std::process::exit(0); } } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8496d0ed..7c9c373a 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -13,7 +13,7 @@ use super::{ mod clipboard; mod commands; mod control; -mod files; +pub(crate) mod files; mod frame; mod ime; mod input; @@ -30,6 +30,11 @@ pub use keys::key_name; const STATUS_LINGER_MS: f64 = 6000.0; impl SlideApp { + /// Remove temporary PDFs created for printing. + pub fn cleanup_print_previews(&mut self) { + self.print_previews.cleanup(); + } + /// Build application UI state, taking ownership of audio output and initialising media, dialogs, /// synthetic input, and timing fields. pub fn new(session: Session, mut services: Services) -> Self { @@ -77,6 +82,7 @@ impl SlideApp { integrated_titlebar: false, backstage: None, pending_urls: vec![], + print_previews: files::PrintPreviews::default(), #[cfg(not(target_arch = "wasm32"))] updates_rt: updates_ui::Runtime::default(), pending_copy: None, diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 0f3c944f..1e846e0d 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -3,6 +3,66 @@ use super::{ json, media, }; +#[derive(Default)] +pub(crate) struct PrintPreviews(Vec); + +impl PrintPreviews { + fn create(&mut self, title: &str) -> std::io::Result { + self.create_in(&std::env::temp_dir(), title) + } + + fn create_in(&mut self, base: &std::path::Path, title: &str) -> std::io::Result { + let dir = create_private_print_dir(base)?; + self.0.push(dir.clone()); + let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); + Ok(dir.join(format!("{safe} (print).pdf"))) + } + + fn discard(&mut self, path: &std::path::Path) { + if let Some(dir) = path.parent() { + let _ = std::fs::remove_dir_all(dir); + self.0.retain(|owned| owned != dir); + } + } + + pub(crate) fn cleanup(&mut self) { + for dir in self.0.drain(..) { + let _ = std::fs::remove_dir_all(dir); + } + } +} + +impl Drop for PrintPreviews { + fn drop(&mut self) { + self.cleanup(); + } +} + +fn create_private_print_dir(base: &std::path::Path) -> std::io::Result { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT_DIR: AtomicU64 = AtomicU64::new(0); + + for _ in 0..128 { + let id = NEXT_DIR.fetch_add(1, Ordering::Relaxed); + let path = base.join(format!("deckcraft-print-{}-{id}", std::process::id())); + #[cfg(unix)] + let mut builder = std::fs::DirBuilder::new(); + #[cfg(not(unix))] + let builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(&path) { + Ok(()) => return Ok(path), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "could not allocate a unique print preview directory")) +} + fn purpose_of(purpose: &str) -> FilePurpose { match purpose { "open" => FilePurpose::Open, @@ -207,8 +267,14 @@ impl SlideApp { let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, ¶ms); return self.download(&format!("{title}.pdf"), bytes); } - let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); - let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string(); + let preview_path = match self.print_previews.create(&title) { + Ok(path) => path, + Err(e) => { + self.set_status(format!("Couldn't create a private print preview: {e}")); + return false; + } + }; + let path = preview_path.to_string_lossy().to_string(); if let Some(o) = params.as_object_mut() { o.insert("path".into(), json!(path)); o.insert("format".into(), json!("pdf")); @@ -219,7 +285,10 @@ impl SlideApp { self.set_status("Opened the presentation as a PDF to print"); true } - Err(_) => false, + Err(_) => { + self.print_previews.discard(&preview_path); + false + } } } @@ -665,3 +734,49 @@ pub(crate) fn web_image_files( }) .collect() } + +#[cfg(test)] +mod print_preview_tests { + use super::PrintPreviews; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn same_title_gets_distinct_private_paths_and_drop_cleans_them() { + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); + let base = std::env::temp_dir().join(format!("deckcraft-print-test-{}-{stamp}", std::process::id())); + std::fs::create_dir(&base).unwrap(); + let (first, second) = { + let mut previews = PrintPreviews::default(); + let first = previews.create_in(&base, "Same title").unwrap(); + let second = previews.create_in(&base, "Same title").unwrap(); + assert_ne!(first, second); + assert_eq!(first.file_name(), second.file_name()); + assert!(first.parent().is_some_and(|dir| dir.is_dir())); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(first.parent().unwrap()).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o700); + } + (first, second) + }; + assert!(!first.parent().is_some_and(|dir| dir.exists())); + assert!(!second.parent().is_some_and(|dir| dir.exists())); + std::fs::remove_dir(base).unwrap(); + } + + #[test] + fn explicit_cleanup_removes_previews() { + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); + let base = std::env::temp_dir().join(format!("deckcraft-print-cleanup-test-{}-{stamp}", std::process::id())); + std::fs::create_dir(&base).unwrap(); + let mut previews = PrintPreviews::default(); + let path = previews.create_in(&base, "Explicit cleanup").unwrap(); + let dir = path.parent().unwrap().to_path_buf(); + + previews.cleanup(); + + assert!(!dir.exists()); + std::fs::remove_dir(base).unwrap(); + } +} diff --git a/crates/ui-egui/src/backstage/tests.rs b/crates/ui-egui/src/backstage/tests.rs index 00a4aec6..22a27972 100644 --- a/crates/ui-egui/src/backstage/tests.rs +++ b/crates/ui-egui/src/backstage/tests.rs @@ -634,13 +634,25 @@ fn print_previews_pages_and_prints_a_pdf() { h.state_mut().backstage.as_mut().unwrap().print_range = "1-2".into(); h.run_steps(2); click_on_page(&h, "Print"); - h.run_steps(2); - let url = h.state().pending_urls.first().cloned().unwrap_or_default(); - // The URL was handed to egui at the end of the frame; check what it pointed at. - let printed = std::env::temp_dir().join(format!("{} (print).pdf", h.state().session.active().unwrap().title())); - assert!(printed.is_file(), "the PDF to print was written: {url}"); - assert!(std::fs::read(&printed).unwrap().starts_with(b"%PDF")); - let _ = std::fs::remove_file(printed); + h.step(); + let url = h + .output() + .platform_output + .commands + .iter() + .find_map(|command| match command { + egui::OutputCommand::OpenUrl(open) => Some(open.url.as_str()), + _ => None, + }) + .expect("Print emits an egui OpenUrl command"); + assert!(url.contains("deckcraft-print-"), "print preview uses its private directory: {url}"); + let path = url.strip_prefix("file://").expect("print preview URL is a file URL"); + #[cfg(windows)] + let path = path.strip_prefix('/').unwrap_or(path); + let printed = std::path::Path::new(path); + assert!(printed.is_file(), "the PDF at the emitted URL was written: {url}"); + assert!(std::fs::read(printed).unwrap().starts_with(b"%PDF")); + let _ = std::fs::remove_dir_all(printed.parent().unwrap()); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0937cac6..b4e531ba 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -293,6 +293,7 @@ pub struct SlideApp { /// creating or closing a presentation leaves it. pub backstage: Option, pending_urls: Vec, + print_previews: app::files::PrintPreviews, #[cfg(not(target_arch = "wasm32"))] updates_rt: updates_ui::Runtime, /// Text for the system clipboard from a Copy or Cut command, handed over on the next frame.