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| {