Merge storytold/pdfcraft main into fix/windows-print-dll-delay-load-2630

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-03 22:32:47 +08:00
commit 50a58f91fa
282 files changed
+29943 -3286

No files matched your search

+21 -29
View File
@@ -1,6 +1,8 @@
//! PdfCraft desktop app.
//!
//! Usage: `pdfcraft [options] [files…]` (`-h` / `--help` prints a short version of this, [`USAGE`]).
//! `--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):
@@ -8,7 +10,7 @@
//! --theme light|dark|system --language auto|<code> --mode all|read|edit|convert|sign --tool <catalogue id> --left open|closed
//! --organize on --fields on --dialog properties|shortcuts|about --palette <query> --home on
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|<percent> --always-default-view on|off
//! --page-gaps on|off --default-page-gaps on|off
//! --page-gaps on|off --default-page-gaps on|off --recent-files 0..50
//! --combine-view grid|list --combine-zoom 60..200`
//!
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
@@ -40,6 +42,7 @@ mod motion;
mod reopen;
mod settings;
mod single_instance;
mod startup;
mod text_scale;
#[cfg(test)]
#[path = "windows_manifest.rs"]
@@ -63,6 +66,7 @@ Usage: pdfcraft [options] [files…]
Opens the files in the PdfCraft desktop app.
Options:
--two-paged open command-line PDFs in noncontinuous two-page view
--new-window open a window of its own instead of passing the files to a running PdfCraft
--create-images [images…] put the images in one PDF and ask for the page DPI
--control <file> enable the UI control channel (pdfcraft-cli ui --control <file> …)
@@ -218,28 +222,16 @@ 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<String> = 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" => return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version())),
// Before any window or log file, so a scripted call returns (#4076).
"-h" | "--help" => return print_and_exit(USAGE),
"--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 mut startup = startup::Arguments::parse(std::env::args().skip(1));
if startup.version {
return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version()));
}
let files = rejoin_split_paths(files, |p| std::path::Path::new(p).is_file());
if startup.help {
return print_and_exit(USAGE);
}
startup.files = rejoin_split_paths(startup.files, |p| std::path::Path::new(p).is_file());
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");
// GNOME on Wayland leaves the window frame to the app (src/decorations.rs).
let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() };
@@ -260,7 +252,6 @@ fn main() -> eframe::Result {
// Explorer double-click, a file manager or `xdg-open`) hands them to the PdfCraft already
// running and exits (#282, #317, #1726). 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;
// Absolute for this instance too: links relative to a document resolve against its folder,
// never the working directory.
let files = single_instance::absolute(&files);
@@ -270,7 +261,7 @@ fn main() -> eframe::Result {
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, window_controls };
let launch = Launch { files, options, control_file, create_images, integrated, window_controls, 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")]
@@ -380,6 +371,7 @@ struct Launch {
integrated: bool,
/// The window buttons the app draws, when the desktop leaves the frame to it.
window_controls: Option<pdfcraft_ui_egui::window_controls::Layout>,
two_paged: bool,
}
/// Whether the window is a native Wayland one (not X11 or XWayland).
@@ -400,7 +392,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, window_controls } = launch;
let Launch { files, options, control_file, create_images, integrated, window_controls, two_paged } = launch;
Box::new(move |cc| {
started.set(true);
let mut app = PdfCraftApp::new();
@@ -467,6 +459,7 @@ fn app_creator<'a>(
&[("folder", &w.folder.display().to_string()), ("error", &w.error)],
);
}
let defer_layout = two_paged && !files.is_empty() && !create_images;
if create_images {
if let Err(e) = app.begin_image_import_paths(&files) {
app.notify(e);
@@ -475,12 +468,11 @@ 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) {
let result = if defer_layout && k == "layout" { app.set_launch_layout(&v) } else { app.set_option(&k, &v) };
if let Err(e) = result {
log::warn!("--{k} {v}: {e}");
}
}
+244
View File
@@ -0,0 +1,244 @@
//! Native launch arguments and per-file view overrides (#1354).
use pdfcraft_ui_egui::PdfCraftApp;
#[derive(Default)]
pub(super) struct Arguments {
pub files: Vec<String>,
pub options: Vec<(String, String)>,
pub control_file: Option<String>,
pub create_images: bool,
pub new_window: bool,
pub two_paged: bool,
pub version: bool,
pub help: 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<Item = String>) -> 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;
}
"-h" | "--help" => {
parsed.help = 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 newer_options_and_version_are_preserved() {
let parsed = parse(&["--two-paged", "--new-window", "--grid-zoom", "150", "--default-mode", "read", "file:///tmp/a%20b.pdf"]);
assert!(parsed.two_paged && parsed.new_window);
assert_eq!(parsed.files, ["/tmp/a b.pdf"]);
assert_eq!(parsed.options, [("grid-zoom".into(), "150".into()), ("default-mode".into(), "read".into())]);
assert!(!parsed.may_hand_off());
let version = parse(&["--two-paged", "--version", "ignored.pdf"]);
assert!(version.version && version.files.is_empty());
}
#[test]
fn help_keeps_the_headless_early_exit() {
for flag in ["-h", "--help"] {
let args = parse(&["--two-paged", flag, "ignored.pdf"]);
assert!(args.help && args.files.is_empty());
}
}
#[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 second_path = path.with_extension("second.pdf");
std::fs::copy(&path, &second_path).unwrap();
let text = path.to_str().unwrap();
let second = second_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(), second.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.
if enabled {
app.set_launch_layout("continuous").unwrap();
} else {
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();
std::fs::remove_file(second_path).unwrap();
}
#[test]
fn explicit_launch_layout_preserves_argument_order_and_active_file() {
use pdfcraft_ui_egui::canvas::PageLayout;
let path = std::env::temp_dir().join(format!("pdfcraft-explicit-layout-{}.pdf", std::process::id()));
std::fs::write(&path, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap();
let second_path = path.with_extension("second.pdf");
std::fs::copy(&path, &second_path).unwrap();
let names = [path.to_str().unwrap(), second_path.to_str().unwrap()];
for prefix in [["--two-paged", "--layout", "single"], ["--layout", "single", "--two-paged"]] {
for count in [1, 2] {
let args = Arguments::parse(prefix.into_iter().chain(names.iter().take(count).copied()).map(str::to_owned));
let mut app = PdfCraftApp::new();
app.set_option("default-layout", "continuous").unwrap();
app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap();
open_files(&mut app, &args.files, args.two_paged);
for (key, value) in args.options {
assert_eq!(key, "layout");
app.set_launch_layout(&value).unwrap();
}
assert_eq!(app.active, Some(count));
assert!(app.views[0].continuous());
for view in app.views.iter().skip(1).take(count - 1) {
assert_eq!(view.layout, PageLayout::TwoUp);
}
assert_eq!(app.views[count].layout, PageLayout::Single);
assert!(!app.views[count].continuous());
assert!(app.view_defaults.continuous);
}
}
std::fs::remove_file(path).unwrap();
std::fs::remove_file(second_path).unwrap();
}
}
#[cfg(test)]
mod repeated_file_tests {
use super::*;
#[test]
fn a_repeated_file_reuses_its_tab_and_receives_the_launch_override() {
use pdfcraft_ui_egui::canvas::PageLayout;
let path = std::env::temp_dir().join(format!("pdfcraft-repeated-startup-{}.pdf", std::process::id()));
std::fs::write(&path, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap();
let name = path.to_str().unwrap().to_string();
let mut app = PdfCraftApp::new();
app.set_option("default-layout", "continuous").unwrap();
app.open_path(&name);
let original = app.views[0].id;
open_files(&mut app, &[name.clone(), name], true);
assert_eq!(app.views.len(), 1);
assert_eq!(app.views[0].id, original);
assert_eq!(app.views[0].layout, PageLayout::TwoUp);
assert!(!app.views[0].continuous());
app.set_launch_layout("single").unwrap();
assert_eq!(app.views[0].layout, PageLayout::Single);
assert!(app.view_defaults.continuous);
std::fs::remove_file(path).unwrap();
}
}