From bf8d2b337b584fda57324fe653971d35233e268b Mon Sep 17 00:00:00 2001 From: srt8hellcat2-ui <225485253+srt8hellcat2-ui@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:09:31 +0000 Subject: [PATCH 1/4] feat(ui): add optional two-paged CLI flag (#1354) --- README.md | 2 + apps/pdfcraft/src/main.rs | 40 +++------ apps/pdfcraft/src/startup.rs | 153 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 25 ++++++ crates/ui-egui/tests/editing.rs | 53 +++++++++++ 5 files changed, 246 insertions(+), 27 deletions(-) create mode 100644 apps/pdfcraft/src/startup.rs diff --git a/README.md b/README.md index b20179fff4..e3813760d8 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,8 @@ Search the whole document as you type, step through matches with ⌘G ## Navigate long documents +Launch with `pdfcraft --two-paged file.pdf` (or `pdfcraft file.pdf --two-paged`) to open each command-line PDF in noncontinuous two-page view. This optional launch override does not change saved default-layout preferences or PDFs restored from the previous session. An explicit `--layout` option takes precedence on the active document regardless of argument order; `--default-layout` retains its existing behavior for subsequently opened documents. + Right-click a PDF tab to close it, reopen the last closed file-backed tab, or reveal its file. Closing still asks about unsaved changes. Reopening reads the saved file from disk; it does not restore discarded edits. Reveal selects the file in Explorer or Finder. On Linux and FreeBSD it requests `org.freedesktop.FileManager1.ShowItems`, falling back to opening the containing directory with `xdg-open` when that service is unavailable. Reopen and Reveal are unavailable on the web; Reveal is also unavailable for pathless documents. Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index c66d2c68f5..348689a5c0 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -1,6 +1,8 @@ //! PdfCraft desktop app. //! //! Usage: `pdfcraft [options] [files…]` +//! `--two-paged` opens each command-line PDF in noncontinuous two-page view, without changing defaults. +//! An explicit `--layout` takes precedence for the active document, regardless of argument order. //! `--create-images [images…]` stages the images in one PDF and asks for the page DPI. //! //! View options (applied after the files open; also the seed of the UI control channel): @@ -37,6 +39,7 @@ mod logging; mod motion; mod reopen; mod single_instance; +mod startup; mod text_scale; mod updates; #[cfg(test)] @@ -146,28 +149,13 @@ fn main() -> eframe::Result { let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("pdfcraft: {report}\n")); } })); - let mut files = Vec::new(); - let mut options: Vec<(String, String)> = Vec::new(); - let mut control_file: Option = None; - let mut create_images = false; - let mut new_window = false; - let mut args = std::env::args().skip(1); - while let Some(a) = args.next() { - match a.as_str() { - "--version" => { - println!("pdfcraft {}", env!("CARGO_PKG_VERSION")); - return Ok(()); - } - "--control" => control_file = args.next(), - "--create-images" => create_images = true, - "--new-window" => new_window = true, - flag if flag.starts_with("--") => { - let value = args.next().unwrap_or_default(); - options.push((flag.trim_start_matches("--").to_string(), value)); - } - _ => files.push(path_from_arg(a)), - } + let startup = startup::Arguments::parse(std::env::args().skip(1)); + if startup.version { + println!("pdfcraft {}", env!("CARGO_PKG_VERSION")); + return Ok(()); } + let may_hand_off = startup.may_hand_off(); + let startup::Arguments { files, options, control_file, create_images, two_paged, .. } = startup; let integrated = cfg!(target_os = "macos"); migrate_legacy_folders(); // The log file lives in the settings folder; opened after the arguments (so `--version` leaves @@ -183,14 +171,13 @@ fn main() -> eframe::Result { // Windows: a launch that only names files (an Outlook attachment, an Explorer double-click) // hands them to the PdfCraft already running and exits (#282, #317). Any option means the // caller wants this launch's own window. - let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images; let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) { single_instance::Claim::HandedOff => return Ok(()), single_instance::Claim::Primary(server) => Some(server), single_instance::Claim::Alone => None, }; let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref()); - let launch = Launch { files, options, control_file, create_images, integrated }; + let launch = Launch { files, options, control_file, create_images, integrated, two_paged }; // Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one // that launched us as well as later ones. Lives until the event loop returns. #[cfg(target_os = "macos")] @@ -291,6 +278,7 @@ struct Launch { control_file: Option, create_images: bool, integrated: bool, + two_paged: bool, } fn app_creator<'a>( @@ -299,7 +287,7 @@ fn app_creator<'a>( instance: Option<&'a single_instance::Server>, #[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents, ) -> eframe::AppCreator<'a> { - let Launch { files, options, control_file, create_images, integrated } = launch; + let Launch { files, options, control_file, create_images, integrated, two_paged } = launch; Box::new(move |cc| { started.set(true); let mut app = PdfCraftApp::new(); @@ -366,9 +354,7 @@ fn app_creator<'a>( // With the preference on, last session's files come back first; files named on // the command line open after them, in front (#442). app.reopen_last_files(&files); - for f in files { - app.open_path(&f); - } + startup::open_files(&mut app, &files, two_paged); } for (k, v) in options { if let Err(e) = app.set_option(&k, &v) { diff --git a/apps/pdfcraft/src/startup.rs b/apps/pdfcraft/src/startup.rs new file mode 100644 index 0000000000..50ae1198d7 --- /dev/null +++ b/apps/pdfcraft/src/startup.rs @@ -0,0 +1,153 @@ +//! Native launch arguments and per-file view overrides (#1354). +use pdfcraft_ui_egui::PdfCraftApp; + +#[derive(Default)] +pub(super) struct Arguments { + pub files: Vec, + pub options: Vec<(String, String)>, + pub control_file: Option, + pub create_images: bool, + pub new_window: bool, + pub two_paged: bool, + pub version: bool, +} + +impl Arguments { + pub fn may_hand_off(&self) -> bool { + !self.new_window && !self.files.is_empty() && self.options.is_empty() && self.control_file.is_none() && !self.create_images && !self.two_paged + } + + pub fn parse(args: impl IntoIterator) -> Self { + let mut parsed = Self::default(); + let mut args = args.into_iter(); + while let Some(a) = args.next() { + match a.as_str() { + "--version" => { + parsed.version = true; + break; + } + "--control" => parsed.control_file = args.next(), + "--create-images" => parsed.create_images = true, + "--new-window" => parsed.new_window = true, + "--two-paged" => parsed.two_paged = true, + flag if flag.starts_with("--two-paged=") => { + // It has no value: do not consume the following filename on malformed use. + log::warn!("{flag}: use --two-paged without a value"); + } + flag if flag.starts_with("--") => { + let value = args.next().unwrap_or_default(); + parsed.options.push((flag.trim_start_matches("--").to_string(), value)); + } + _ => parsed.files.push(super::path_from_arg(a)), + } + } + parsed + } +} + +pub(super) fn open_files(app: &mut PdfCraftApp, files: &[String], two_paged: bool) { + for path in files { + if two_paged { + // The per-file override wins over initial view without changing saved defaults. + app.open_path_with_display(path, pdfcraft_ui_egui::canvas::PageLayout::TwoUp, false); + } else { + app.open_path(path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Arguments { + Arguments::parse(args.iter().map(|s| s.to_string())) + } + + #[test] + fn standalone_flag_keeps_files_in_either_position() { + for args in + [["--two-paged", "first.pdf", "second.pdf"], ["first.pdf", "--two-paged", "second.pdf"], ["first.pdf", "second.pdf", "--two-paged"]] + { + let parsed = parse(&args); + assert!(parsed.two_paged); + assert_eq!(parsed.files, ["first.pdf", "second.pdf"]); + assert!(parsed.options.is_empty()); + assert!(!parsed.may_hand_off()); + } + let empty = parse(&["--two-paged"]); + assert!(empty.two_paged && empty.files.is_empty()); + let normal = parse(&["first.pdf", "second.pdf"]); + assert!(!normal.two_paged); + assert!(normal.may_hand_off()); + for args in [ + &["--new-window", "first.pdf"][..], + &["--layout", "two-page", "first.pdf"], + &["--control", "control.json", "first.pdf"], + &["--create-images", "first.png"], + ] { + assert!(!parse(args).may_hand_off()); + } + } + + #[test] + fn existing_layout_options_are_preserved_in_either_order() { + for args in [["--two-paged", "--layout", "single", "file.pdf"], ["--layout", "single", "--two-paged", "file.pdf"]] { + let parsed = parse(&args); + assert!(parsed.two_paged); + assert_eq!(parsed.files, ["file.pdf"]); + assert_eq!(parsed.options, [("layout".into(), "single".into())]); + } + let parsed = parse(&["--two-paged", "--default-layout", "two-up", "file.pdf"]); + assert_eq!(parsed.options, [("default-layout".into(), "two-up".into())]); + } + + #[test] + fn malformed_boolean_does_not_eat_a_file_or_option() { + let args = parse(&["--two-paged=false", "first.pdf", "--layout", "single", "second.pdf"]); + assert!(!args.two_paged); + assert_eq!(args.files, ["first.pdf", "second.pdf"]); + assert_eq!(args.options, [("layout".into(), "single".into())]); + } + + #[test] + fn startup_override_is_per_file_and_does_not_persist() { + use std::io::Write as _; + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("pdfcraft-two-paged-{}-{nonce}.pdf", std::process::id())); + let mut file = std::fs::OpenOptions::new().create_new(true).write(true).open(&path).unwrap(); + file.write_all(include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap(); + drop(file); + let text = path.to_str().unwrap(); + for enabled in [false, true] { + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "single").unwrap(); + app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap(); + let restored = app.views[0].id; + open_files(&mut app, &[text.into(), text.into()], enabled); + assert_eq!(app.views.len(), 3); + assert_eq!(app.views[0].id, restored); + assert_eq!(app.views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); + for view in app.views.iter().skip(1) { + assert_eq!( + view.layout, + if enabled { pdfcraft_ui_egui::canvas::PageLayout::TwoUp } else { pdfcraft_ui_egui::canvas::PageLayout::Single } + ); + assert!(!view.continuous()); + } + let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap(); + assert_eq!(persisted["default_layout"], "single"); + assert!(persisted.get("two_paged").is_none()); + // Existing --layout applies after the flag to the active document only. + app.set_option("layout", "continuous").unwrap(); + assert_eq!(app.views[2].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); + assert!(app.views[2].continuous()); + app.set_option("default-layout", "two-up").unwrap(); + assert_eq!(app.view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp); + assert!(app.view_defaults.continuous); + open_files(&mut app, &[], enabled); + assert_eq!(app.views.len(), 3); + } + std::fs::remove_file(path).unwrap(); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2d4f5b0993..f165f2754f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -404,6 +404,8 @@ pub struct PasswordPrompt { then: Option<&'static str>, /// A link destination to apply after the linked PDF is unlocked. after_open: Option, + /// A launch-time display override, applied only after this PDF is unlocked. + display_override: Option<(canvas::PageLayout, bool)>, } /// Asks for the permissions password of an open document whose security restricts it, which @@ -1056,6 +1058,7 @@ impl PdfCraftApp { recovered: None, then: None, after_open: None, + display_override: None, }); return Ok(()); } @@ -1268,6 +1271,11 @@ impl PdfCraftApp { if let Some(destination) = after_open { self.apply_remote_destination(destination); } + if let Some((layout, continuous)) = p.display_override + && let Some(view) = self.views.last_mut() + { + view.set_display(layout, continuous); + } if let Some(meta) = &p.recovered { self.finish_recovery(meta); } @@ -1282,6 +1290,7 @@ impl PdfCraftApp { prompt.recovered = p.recovered; prompt.then = p.then; prompt.after_open = after_open; + prompt.display_override = p.display_override; } } } @@ -1401,6 +1410,22 @@ impl PdfCraftApp { } } + /// Open one native file with a transient display override, including after a password prompt. + #[cfg(not(target_arch = "wasm32"))] + pub fn open_path_with_display(&mut self, path: &str, layout: canvas::PageLayout, continuous: bool) { + let tabs = self.views.len(); + self.open_path(path); + if self.views.len() > tabs { + if let Some(view) = self.views.last_mut() { + view.set_display(layout, continuous); + } + } else if let Some(prompt) = self.password_prompt.as_mut() + && prompt.path.as_deref() == Some(path) + { + prompt.display_override = Some((layout, continuous)); + } + } + /// The File ▸ Open picker: PDFs, and images and text that become PDFs. #[cfg(not(target_arch = "wasm32"))] fn open_picker() -> rfd::AsyncFileDialog { diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 56e7cce300..0e62d6f52e 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1892,6 +1892,59 @@ fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, p pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() } +#[test] +fn launch_display_override_survives_password_retry_without_changing_defaults() { + use pdfcraft_ui_egui::canvas::PageLayout; + use std::io::Write as _; + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = temp_path(&format!("startup-layout-{nonce}.pdf")); + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); + file.write_all(&protected("reader", "owner", -4)).unwrap(); + drop(file); + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "continuous").unwrap(); + app.open_path_with_display(path.to_str().unwrap(), PageLayout::TwoUp, false); + assert!(app.views.is_empty() && app.password_prompt.is_some()); + app.submit_password(Some("wrong".into())); + assert!(app.views.is_empty() && app.password_prompt.is_some()); + app.submit_password(Some("reader".into())); + assert_eq!(app.views.len(), 1); + assert_eq!(app.views[0].layout, PageLayout::TwoUp); + assert!(!app.views[0].continuous()); + let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap(); + assert_eq!(persisted["default_layout"], "continuous"); + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn canceling_launch_display_override_does_not_affect_later_documents() { + use pdfcraft_ui_egui::canvas::PageLayout; + use std::io::Write as _; + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = temp_path(&format!("startup-cancel-layout-{nonce}.pdf")); + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); + file.write_all(&protected("reader", "owner", -4)).unwrap(); + drop(file); + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "continuous").unwrap(); + app.open_path_with_display(path.to_str().unwrap(), PageLayout::TwoUp, false); + assert!(app.views.is_empty() && app.password_prompt.is_some()); + app.submit_password(None); + assert!(app.views.is_empty() && app.password_prompt.is_none()); + app.open_bytes("next.pdf", None, fixture(2)).unwrap(); + assert_eq!(app.views[0].layout, PageLayout::Single); + assert!(app.views[0].continuous()); + // Reopening the same protected file normally must not resurrect the canceled override. + app.open_path(path.to_str().unwrap()); + app.submit_password(Some("reader".into())); + assert_eq!(app.views.len(), 2); + assert_eq!(app.views[1].layout, PageLayout::Single); + assert!(app.views[1].continuous()); + let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap(); + assert_eq!(persisted["default_layout"], "continuous"); + std::fs::remove_file(path).unwrap(); +} + #[test] fn password_prompt_opens_and_security_tab_reports_the_details() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { From 0807cb05c3ce280a41decd78f0829b368f7341f7 Mon Sep 17 00:00:00 2001 From: JM Date: Tue, 13 Oct 2026 17:35:45 +0000 Subject: [PATCH 2/4] fix(ui): preserve tool commands across document opening --- crates/ui-egui/src/lib.rs | 61 ++++++-- crates/ui-egui/src/open_worker_tests.rs | 185 ++++++++++++++++++++++++ crates/ui-egui/tests/commands.rs | 17 ++- 3 files changed, 245 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ab87094886..b3ac430ab4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1193,11 +1193,15 @@ impl PdfCraftApp { open_worker::ResultKind::Failed(reason) => { self.notify_fmt("Couldn't open {name}: {reason}", &[("name", &pending.name), ("reason", &reason)]); } - open_worker::ResultKind::Conversion(bytes) => { - if let Some(Err(error)) = self.open_converted(&pending.name, &bytes) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]); + open_worker::ResultKind::Conversion(bytes) => match self.open_converted(&pending.name, &bytes) { + Some(Ok(())) => { + if let Some(command) = pending.then { + self.execute(command); + } } - } + Some(Err(error)) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error)]), + None => {} + }, open_worker::ResultKind::Prepared { bytes, result } => match *result { Ok(prepared) => match self.finish_open(&pending.name, pending.path.clone(), bytes.len(), prepared, pending.activate) { Ok(id) => { @@ -1727,6 +1731,13 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] pub fn open_path(&mut self, path: &str) { + self.open_path_attempt(path); + } + + /// Whether this attempt opened a document, created a password prompt, or queued an open. + /// Failure can leave an older password prompt intact; it does not belong to this attempt. + #[cfg(not(target_arch = "wasm32"))] + fn open_path_attempt(&mut self, path: &str) -> bool { let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); // Taken before reading: a build that rewrites the file meanwhile still counts as a change. let stamp = file_watch::Stamp::at(path); @@ -1734,20 +1745,26 @@ impl PdfCraftApp { // deterministic inline startup and test flows. Once live, reading and preparation use // the bounded worker and commit on the UI thread. if self.ctx.is_none() || self.run_inline { - match std::fs::read(path) { + return match std::fs::read(path) { Ok(bytes) => { let before = self.views.len(); if let Err(error) = self.open_bytes(&name, Some(path.to_string()), bytes) { self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); - } else if self.views.len() > before - && let Some(doc) = self.views.last().map(|view| view.id) - { - self.watch.known_as(doc, stamp); + false + } else { + if self.views.len() > before + && let Some(doc) = self.views.last().map(|view| view.id) + { + self.watch.known_as(doc, stamp); + } + true } } - Err(error) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]), - } - return; + Err(error) => { + self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &error.to_string())]); + false + } + }; } self.startup_superseded = true; if let Err(error) = self.schedule_open(OpenRequest { @@ -1762,6 +1779,9 @@ impl PdfCraftApp { then: None, }) { self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]); + false + } else { + true } } @@ -2064,12 +2084,21 @@ impl PdfCraftApp { self.ask_one(pickers::Ask::File(dialog), None, move |app, path| { let path = path.to_string_lossy().into_owned(); let before = app.views.len(); - app.open_path(&path); - if let Some(prompt) = app.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path.as_str())) { - // Encrypted: the tool runs once the password is in. - prompt.then = Some(command); + let pending_before = app.pending_opens.len(); + if !app.open_path_attempt(&path) { + return; + } + if app.ctx.is_some() && !app.run_inline { + // UI-thread completion cannot run inside open_path: only its newly queued + // request owns this continuation, including through password retries. + if let Some(pending) = app.pending_opens.get_mut(pending_before) { + pending.then = Some(command); + } } else if app.views.len() > before && app.active.is_some() { app.execute(command); + } else if let Some(prompt) = app.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path.as_str())) { + // Encrypted: the tool runs once the password is in. + prompt.then = Some(command); } }); } diff --git a/crates/ui-egui/src/open_worker_tests.rs b/crates/ui-egui/src/open_worker_tests.rs index 0e5775feb1..f71c9fbebe 100644 --- a/crates/ui-egui/src/open_worker_tests.rs +++ b/crates/ui-egui/src/open_worker_tests.rs @@ -89,6 +89,191 @@ fn open_prepare_keeps_control_and_existing_tabs_live_until_atomic_commit() { let _ = std::fs::remove_file(path); } +#[test] +fn inline_failed_tool_open_does_not_steal_an_existing_password_prompt() { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "reader", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("pdfcraft-inline-owner-{}-{nonce}.pdf", std::process::id())); + assert!(!path.exists(), "the new opening attempt must fail reading"); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.run_inline = true; + app.open_bytes("locked.pdf", Some(path.to_string_lossy().into_owned()), bytes).unwrap(); + app.pick_override = Some(vec![path.to_string_lossy().into_owned()]); + app + }); + h.step(); + let cached = h.state().password_prompt.as_ref().unwrap().bytes.clone(); + assert!(h.state().password_prompt.as_ref().unwrap().then.is_none()); + h.state_mut().run_command("export.text"); + h.step(); + assert!(h.state().toast.as_ref().is_some_and(|toast| toast.0.starts_with("Couldn't read"))); + let prompt = h.state().password_prompt.as_ref().unwrap(); + assert!(Arc::ptr_eq(&prompt.bytes, &cached), "the older encrypted bytes remain available"); + assert!(prompt.then.is_none(), "a failed attempt must not attach its tool to the older prompt"); + h.state_mut().submit_password(Some("wrong".into())); + assert!(h.state().password_prompt.as_ref().unwrap().then.is_none()); + h.state_mut().submit_password(Some("reader".into())); + assert_eq!(h.state().views.len(), 1, "the older document can still be unlocked"); + assert!(h.state().password_prompt.is_none()); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "the failed attempt's tool never runs"); + + // Accepting the current attempt must still run ordinary/conversion tools and retain + // newly created password continuations through retries or discard them on cancellation. + for case in ["plain", "converted", "password", "password-cancel"] { + let path = path.with_extension(if case == "converted" { "txt" } else { "pdf" }); + let bytes = match case { + "plain" => fixture(2), + "converted" => b"Inline text converted to PDF".to_vec(), + _ => cached.as_ref().clone(), + }; + std::fs::write(&path, bytes).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.run_inline = true; + app.pick_override = Some(vec![path.to_string_lossy().into_owned()]); + app + }); + h.step(); + let defaults = h.state().persist(); + h.state_mut().run_command("export.text"); + h.step(); + if case.starts_with("password") { + assert_eq!(h.state().password_prompt.as_ref().unwrap().then, Some("export.text")); + h.state_mut().submit_password(Some("wrong".into())); + assert_eq!(h.state().password_prompt.as_ref().unwrap().then, Some("export.text")); + h.state_mut().submit_password((case == "password").then(|| "reader".into())); + } + assert_eq!(h.state().views.len(), usize::from(case != "password-cancel")); + assert_eq!(matches!(h.state().dialog, Some(crate::Dialog::Export(_))), case != "password-cancel"); + assert!(h.state().password_prompt.is_none()); + assert_eq!(h.state().view_defaults.layout, PdfCraftApp::new().view_defaults.layout); + assert_eq!(h.state().view_defaults.continuous, PdfCraftApp::new().view_defaults.continuous); + // Persistence includes recent files; compare only the saved display defaults. + let before: Value = serde_json::from_str(&defaults).unwrap(); + let after: Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(before["default_layout"], after["default_layout"]); + let _ = std::fs::remove_file(path); + } +} + +#[test] +fn tool_open_continuation_follows_its_worker_and_password_retries() { + for case in ["plain", "converted", "invalid-image", "password", "password-cancel", "wrong-password-cancel", "read-failure", "invalid-pdf"] { + let extension = match case { + "converted" => "txt", + "invalid-image" => "png", + _ => "pdf", + }; + let path = std::env::temp_dir().join(format!("pdfcraft-tool-worker-{}-{case}.{extension}", std::process::id())); + let encrypted = case.contains("password"); + if case != "read-failure" { + let bytes = if encrypted { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "reader", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() + } else if case == "converted" { + b"A text file opened as a PDF".to_vec() + } else if case == "invalid-image" { + b"\x89PNG\r\n\x1a\n".to_vec() + } else if case == "invalid-pdf" { + b"not a PDF".to_vec() + } else { + fixture(2) + }; + std::fs::write(&path, bytes).unwrap(); + } + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.pick_override = Some(vec![path.to_string_lossy().into_owned()]); + app + }); + h.step(); + let (gate, started, release, finished) = open_worker::prepare_gate(); + h.state_mut().open_prepare_gate = Some(gate); + h.state_mut().run_command("export.text"); + h.step(); + started.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(h.state().pending_opens.len(), 1); + assert_eq!(h.state().pending_opens[0].then, Some("export.text"), "{case}: command belongs to the pending request"); + + // A different document becoming active must neither run nor inherit the tool. + h.state_mut().open_bytes("unrelated.pdf", None, fixture(1)).unwrap(); + let unrelated = h.state().views[0].id; + h.step(); + assert!(h.state().dialog.is_none()); + release.send(()).unwrap(); + finished.recv_timeout(Duration::from_secs(5)).unwrap(); + h.step(); + + if encrypted { + assert_eq!(h.state().password_prompt.as_ref().unwrap().then, Some("export.text")); + assert!(h.state().dialog.is_none()); + if case != "password-cancel" { + h.state_mut().submit_password(Some("wrong".into())); + started.recv_timeout(Duration::from_secs(5)).unwrap(); + release.send(()).unwrap(); + finished.recv_timeout(Duration::from_secs(5)).unwrap(); + h.step(); + let prompt = h.state().password_prompt.as_ref().unwrap(); + assert!(prompt.error.is_some()); + assert_eq!(prompt.then, Some("export.text")); + assert!(h.state().dialog.is_none()); + } + if case == "password" { + h.state_mut().submit_password(Some("reader".into())); + started.recv_timeout(Duration::from_secs(5)).unwrap(); + release.send(()).unwrap(); + finished.recv_timeout(Duration::from_secs(5)).unwrap(); + h.step(); + } else { + h.state_mut().submit_password(None); + } + } + if matches!(case, "plain" | "converted" | "password") { + assert_eq!(h.state().views.len(), 2); + let app = h.state(); + let opened = &app.views[app.active.unwrap()]; + assert_ne!(opened.id, unrelated); + assert_eq!(app.session.get(opened.id).unwrap().path.as_deref(), if case == "converted" { None } else { path.to_str() }); + assert!(matches!(app.dialog, Some(crate::Dialog::Export(_)))); + // Closing the dialog and processing more frames must not execute it again. + h.state_mut().dialog = None; + } else { + assert_eq!(h.state().views.len(), 1); + assert!(h.state().dialog.is_none()); + } + assert!(h.state().pending_opens.is_empty()); + assert!(h.state().password_prompt.is_none()); + h.state_mut().open_bytes("later.pdf", None, fixture(1)).unwrap(); + h.run_steps(2); + assert!(h.state().dialog.is_none(), "{case}: continuation cannot run on another open"); + let _ = std::fs::remove_file(path); + } +} + #[test] fn launch_layout_waits_for_cli_opens_and_preserves_restored_tabs() { use crate::canvas::PageLayout; diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 47bbc21a8f..734f35099b 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -44,6 +44,15 @@ fn harness() -> Harness<'static, PdfCraftApp> { h } +fn wait_for(h: &mut Harness<'static, PdfCraftApp>, description: &str, ready: impl Fn(&PdfCraftApp) -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !ready(h.state()) { + assert!(std::time::Instant::now() < deadline, "timed out waiting for {description}"); + h.step(); + std::thread::yield_now(); + } +} + /// Commands that open a native file picker can't run headless. /// (Create ▸ Clipboard reads the system clipboard: tests use `create_from_clip`.) const PICKERS: &[&str] = &[ @@ -184,7 +193,7 @@ fn a_tool_chosen_with_no_document_asks_for_one_and_runs_on_it() { h.run_steps(2); h.state_mut().run_command("export.text"); assert!(h.state().toast.as_ref().is_none_or(|t| t.0 != "Open a document first")); - h.run_steps(3); + wait_for(&mut h, "picked document and export dialog", |app| app.active.is_some() && matches!(app.dialog, Some(Dialog::Export(_)))); assert!(h.state().active.is_some(), "the picked file is open"); assert!(matches!(h.state().dialog, Some(Dialog::Export(_))), "and the tool runs on it: {:?}", h.state().dialog); // Cancelled: nothing opens and nothing runs. @@ -287,7 +296,11 @@ fn the_open_recent_menu_lists_files_and_opens_one() { h.get_by_label("Open Recent ⏵").hover(); h.run_steps(3); h.get_by_label_contains("recent.pdf").click(); - h.run_steps(4); + wait_for(&mut h, "recent document", |app| { + app.views.len() == 2 + && app.active.and_then(|index| app.views.get(index)).and_then(|view| app.session.get(view.id)).and_then(|doc| doc.path.as_deref()) + == Some(path.as_str()) + }); let app = h.state(); assert_eq!(app.views.len(), 2, "the recent file opened in a new tab"); let active = app.active.unwrap(); From 198f551fa7d48c137578c291ada21b02f5837843 Mon Sep 17 00:00:00 2001 From: srt8hellcat2-ui Date: Sun, 18 Oct 2026 19:57:26 -0400 Subject: [PATCH 3/4] docs: correct two-paged PR publication status --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index cde798d466..444d7b0633 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -113,7 +113,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. #1354 local candidate: --two-paged overrides follow named PDFs through background opens and password retries, preserve defaults and respect single-tab reuse. Native regressions, visual inspection, strict scoped Clippy and scoped WASM compilation pass; three existing Unix-socket tests require an unrestricted runtime. No push or GitHub merge. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. #1354 local candidate: --two-paged overrides follow named PDFs through background opens and password retries, preserve defaults and respect single-tab reuse. Native regressions, visual inspection, strict scoped Clippy and scoped WASM compilation pass; three existing Unix-socket tests require an unrestricted runtime. The PR branch is pushed but not merged into upstream. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | From b77699a22b043c3fa861ab5dd9fbdc5a6f4b77a4 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Sat, 31 Oct 2026 20:33:48 +0530 Subject: [PATCH 4/4] test(optimize): accept Flate or CCITT compression in monochrome scan test --- crates/optimize/tests/optimize.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2e3ea2d42f..683cbd0684 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1775,7 +1775,8 @@ fn monochrome_images_keep_what_they_should() { optimize(&mut doc, &Settings::default()).unwrap(); assert_eq!([alone, deep].map(|r| raw(&doc, r)), before, "a Group 4 halftone alone, and sixteen-bit grey"); assert_eq!(filters(&doc, wrapped), 1, "a wrapped Group 4 halftone is stored again alone by default"); - assert_eq!(stream(&doc, scan).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "the scan: Group 4 is smaller"); + assert!(matches!(stream(&doc, scan).filters().as_slice(), [(Filter::CcittFax | Filter::Flate, _)]), "the scan: Group 4 or Flate"); + assert_eq!(doc.decoded_image(&stream(&doc, scan)).unwrap(), ragged, "the scan preserves every sample"); assert_eq!((stream(&doc, fax).dict.name(b"Filter"), filters(&doc, fax)), (Some(&b"CCITTFaxDecode"[..]), 1), "the busy fax image"); let (mut doc, [_, wrapped, scan, _, _]) = build();