From 86a4120ae093076f10fef88ce298b92819dd07a1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 16:00:23 -0400 Subject: [PATCH 01/28] feat: startup update prompt modal and direct self-updating (#4654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: startup update prompt modal and direct self-updating Help ▸ Check for updates and the check at start run through craft-update's Updater, which replaces the old GitHub fetcher and update source. The check at start is opt-in for the desktop app (test harnesses never reach the network), an install holds back while documents are unsaved, and a finished update closes through the app's quit path. craft-update stays out of the wasm build. Ref: storytold/craft-libs#319 * test(updates): the prompt's buttons are DOWNLOAD and LATER * build: pin craft-update to craft-libs main (db19c37) * fix(updates): run the Windows update helper before argument parsing * fix(updates): point the parity evidence at the current update tests --- Cargo.lock | 133 +++++++++++++++++-- Cargo.toml | 1 + apps/pdfcraft/Cargo.toml | 5 +- apps/pdfcraft/src/main.rs | 8 +- apps/pdfcraft/src/updates.rs | 127 ------------------ crates/ui-egui/Cargo.toml | 2 + crates/ui-egui/src/lib.rs | 2 - crates/ui-egui/src/updates.rs | 198 ++++++++++++----------------- crates/ui-egui/tests/it/updates.rs | 69 ++++++---- parity/acrobat-features.toml | 4 +- 10 files changed, 261 insertions(+), 288 deletions(-) delete mode 100644 apps/pdfcraft/src/updates.rs diff --git a/Cargo.lock b/Cargo.lock index 0a7e03a82e..947dfb0c82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1181,6 +1181,31 @@ dependencies = [ "serde", ] +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip", +] + [[package]] name = "craft-winprint-gdi" version = "0.1.0" @@ -1895,7 +1920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3327,7 +3352,7 @@ dependencies = [ "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3404,6 +3429,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -3876,7 +3913,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3971,6 +4008,7 @@ version = "0.14.0" dependencies = [ "chrono", "craft-quit", + "craft-update", "eframe", "egui", "fmv-macos-events", @@ -3979,10 +4017,8 @@ dependencies = [ "pdfcraft-ui-egui", "pollster 1.0.1", "raw-window-handle", - "rustls-native-certs", "serde_json", "uds_windows", - "ureq", "wayland-client", "wayland-protocols", "windows", @@ -4407,6 +4443,7 @@ dependencies = [ "chrono", "craft-quit", "craft-ui", + "craft-update", "eframe", "egui", "egui_extras", @@ -5014,7 +5051,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5551,7 +5588,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5564,7 +5601,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5634,7 +5671,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5763,6 +5800,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "send_wrapper" @@ -6295,7 +6336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6517,7 +6558,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6877,6 +6918,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typeid" version = "1.0.3" @@ -6897,7 +6944,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6970,10 +7017,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ "base64 0.23.1", + "flate2", "log", "percent-encoding", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "ureq-proto", "utf8-zero", ] @@ -7125,6 +7174,16 @@ dependencies = [ "vello_common 0.2.0", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -7611,7 +7670,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7735,6 +7794,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -7971,6 +8039,16 @@ dependencies = [ "xml", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winresource" version = "0.1.31" @@ -8384,6 +8462,23 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -8396,6 +8491,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index cb69da41a3..39d11d1eaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0 craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] } # internal pdfcraft-geom = { path = "crates/geom", version = "0.14.0" } pdfcraft-filters = { path = "crates/filters", version = "0.14.0" } diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 02d6bc56c5..15ec5d6f68 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -11,8 +11,6 @@ workspace = true [dependencies] # Help ▸ Check for updates (issue #28): one HTTPS request to GitHub, over rustls with the OS's # trusted roots. No bundled root list (webpki-roots and webpki-root-certs are CDLA-licensed). -ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] } -rustls-native-certs = "0.8" pdfcraft-ui-egui = { workspace = true } # `build_info`: the version and compile-time build label for `--version` and the log. pdfcraft-engine = { workspace = true } @@ -71,3 +69,6 @@ uds_windows = "=1.2.1" [build-dependencies] # Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows. winresource = "0.1" + +[target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 8bc4d32f73..ab6ebf830c 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -41,7 +41,6 @@ mod reopen; mod settings; mod single_instance; mod text_scale; -mod updates; #[cfg(test)] #[path = "windows_manifest.rs"] mod windows_manifest; @@ -195,6 +194,11 @@ fn rejoin_split_paths(args: Vec, exists: impl Fn(&str) -> bool) -> Vec eframe::Result { + // Windows: the update helper is a copy of this program started with `--finish-update`. + #[cfg(windows)] + if let Some(result) = craft_update::self_update::finish(&craft_update::suite::PDFCRAFT, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); // Last-resort guard (AGENTS.md §4): commands, edits, opens and saves catch panics and report @@ -419,7 +423,7 @@ fn app_creator<'a>( { app.motion.source = Some(motion::source()); } - app.update_source = Some(std::sync::Arc::new(updates::latest_release)); + app.set_startup_update_check(true); app.printer_source = Some(pdfcraft_ui_egui::system_printers()); app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows")); #[cfg(target_os = "macos")] diff --git a/apps/pdfcraft/src/updates.rs b/apps/pdfcraft/src/updates.rs deleted file mode 100644 index 686c8cee84..0000000000 --- a/apps/pdfcraft/src/updates.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Asks GitHub for the latest PdfCraft release (Help ▸ Check for updates, issue #28). - -use std::time::Duration; - -use pdfcraft_ui_egui::updates::{RELEASES_PAGE, Release}; - -const LATEST: &str = "https://api.github.com/repos/storytold/pdfcraft/releases/latest"; - -/// The latest release. The answer is untrusted: its size is capped, and only a page under -/// [`RELEASES_PAGE`] is ever offered for download (anything else falls back to that list). -pub fn latest_release() -> Result { - let agent = ureq::Agent::config_builder() - .timeout_global(Some(Duration::from_secs(10))) - .tls_config(ureq::tls::TlsConfig::builder().root_certs(os_roots()?).build()) - .build() - .new_agent(); - let mut response = agent - .get(LATEST) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", concat!("PdfCraft/", env!("CARGO_PKG_VERSION"))) - // Read error statuses ourselves: a 403 from GitHub usually means its anonymous - // rate limit ran out, and the retry time rides along in a response header. - .config() - .http_status_as_error(false) - .build() - .call() - .map_err(|e| format!("couldn't reach GitHub ({e})"))?; - let status = response.status().as_u16(); - if status == 403 { - let reset = response.headers().get("x-ratelimit-reset").and_then(|v| v.to_str().ok()); - return Err(rate_limited(reset, now_unix())); - } - if !(200..300).contains(&status) { - return Err(format!("couldn't reach GitHub (http status: {status})")); - } - let body = response.body_mut().with_config().limit(1 << 20).read_to_string().map_err(|e| format!("unreadable answer ({e})"))?; - parse(&body) -} - -/// The certificate authorities the operating system trusts. -fn os_roots() -> Result { - let found = rustls_native_certs::load_native_certs(); - let certs: Vec> = found.certs.iter().map(|c| ureq::tls::Certificate::from_der(c.as_ref()).to_owned()).collect(); - if certs.is_empty() { - return Err("no trusted certificates found on this system".into()); - } - Ok(ureq::tls::RootCerts::new_with_certs(&certs)) -} - -/// The current Unix time in seconds (0 when the clock gives nothing usable). -fn now_unix() -> u64 { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) -} - -/// What to tell the user when GitHub answers 403: its anonymous quota (60 checks an hour, -/// shared by everyone behind the same address) ran out. `reset` is the `x-ratelimit-reset` -/// header, the Unix time the quota refills; `now` is the current Unix time. Anything -/// surprising (no header, not a number, already past) falls back to asking for patience. -fn rate_limited(reset: Option<&str>, now: u64) -> String { - const FALLBACK: &str = "GitHub is limiting update checks for now (shared hourly quota); try again later"; - let wait = reset.and_then(|r| r.trim().parse::().ok()).and_then(|reset| reset.checked_sub(now)).filter(|&wait| wait > 0); - match wait { - Some(wait) => { - let minutes = wait.div_ceil(60).max(1); - let plural = if minutes == 1 { "" } else { "s" }; - format!("GitHub is limiting update checks for now (shared hourly quota); try again in about {minutes} minute{plural}") - } - None => FALLBACK.into(), - } -} - -fn parse(body: &str) -> Result { - let v: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("unreadable answer ({e})"))?; - let version = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string(); - let url = v["html_url"] - .as_str() - .filter(|u| u.strip_prefix(RELEASES_PAGE).is_some_and(|rest| rest.starts_with('/') && !rest.contains(['?', '#', '\\']))) - .unwrap_or(RELEASES_PAGE) - .to_string(); - Ok(Release { version, url }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn answers_are_read_and_only_our_release_pages_are_offered() { - let r = parse(r#"{"tag_name":"v0.2.0","html_url":"https://github.com/storytold/pdfcraft/releases/tag/v0.2.0"}"#).unwrap(); - assert_eq!(r, Release { version: "v0.2.0".into(), url: "https://github.com/storytold/pdfcraft/releases/tag/v0.2.0".into() }); - for elsewhere in ["https://example.com/pdfcraft.exe", "https://github.com/storytold/pdfcraft/releases.evil/x", "javascript:alert(1)"] { - let r = parse(&format!(r#"{{"tag_name":"v9.9.9","html_url":"{elsewhere}"}}"#)).unwrap(); - assert_eq!(r.url, RELEASES_PAGE, "{elsewhere}"); - } - assert!(parse(r#"{"message":"Not Found"}"#).is_err()); - assert!(parse("").is_err()); - } - - #[test] - fn rate_limit_messages_name_the_wait() { - let limited = "GitHub is limiting update checks for now (shared hourly quota); try again in about 20 minutes"; - assert_eq!(rate_limited(Some("1800001200"), 1800000000), limited); - // Rounds up: 61 seconds out reads as 2 minutes. - assert_eq!( - rate_limited(Some("1800000061"), 1800000000), - "GitHub is limiting update checks for now (shared hourly quota); try again in about 2 minutes" - ); - assert_eq!( - rate_limited(Some("1800000060"), 1800000000), - "GitHub is limiting update checks for now (shared hourly quota); try again in about 1 minute" - ); - // No header, not a number, or already past: ask for patience instead of a time. - let later = "GitHub is limiting update checks for now (shared hourly quota); try again later"; - for bad in [None, Some(""), Some("soon"), Some("1799999999"), Some("1800000000")] { - assert_eq!(rate_limited(bad, 1800000000), later); - } - } - - /// Live: asks GitHub over TLS with the OS's roots (`cargo test -p pdfcraft -- --ignored`). - #[test] - #[ignore = "needs network access"] - fn github_answers_with_the_latest_release() { - let r = latest_release().unwrap(); - assert!(pdfcraft_ui_egui::updates::is_newer(&r.version, "0.0.0"), "{r:?}"); - assert!(r.url.starts_with(RELEASES_PAGE), "{r:?}"); - } -} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b155d117b2..c096254995 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -42,6 +42,7 @@ unicode-bidi = "0.3" skrifa = "0.44" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-update = { workspace = true } # Control-channel tokens. getrandom = "0.4" # Create ▸ Clipboard (images; already in the tree through egui-winit). @@ -83,6 +84,7 @@ js-sys = "0.3" web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator"] } [dev-dependencies] +craft-update = { workspace = true } pdfcraft-xfa = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } # Tests draw PdfCraft text without a system provider: the bundled and monochrome emoji faces must diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d4ee683d4a..79f46bee7b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -700,7 +700,6 @@ pub struct PdfCraftApp { pub fill_text_size: f64, pub dialog: Option, /// How to ask for the latest release (the desktop app sets it; see `updates`). - pub update_source: Option, pub(crate) updates: updates::Updates, /// Lists the Print dialog's printers in the background (the desktop app sets it; see /// [`PrinterSource`]). @@ -1137,7 +1136,6 @@ impl PdfCraftApp { flatten_fill_sign_on_save: false, fill_text_size: fill_sign::TEXT_SIZE, dialog: None, - update_source: None, updates: updates::Updates::default(), printer_source: None, options_source: None, diff --git a/crates/ui-egui/src/updates.rs b/crates/ui-egui/src/updates.rs index 4e7957e15e..fa59f9a89f 100644 --- a/crates/ui-egui/src/updates.rs +++ b/crates/ui-egui/src/updates.rs @@ -1,12 +1,8 @@ -//! Help ▸ Check for updates (issue #28): ask for the latest release and offer its download page. -//! -//! The desktop app supplies how to ask ([`PdfCraftApp::update_source`]), so this crate has no -//! network code; without a source (the web build, tests) the command opens the releases page. -//! PdfCraft never downloads or installs anything itself: the user downloads the new version. -//! It asks only when the user does: there is no check at start (the owner's decision). - -use std::sync::Arc; +//! Help ▸ Check for updates and the check at start, through `craft_update`: the flow itself is its +//! `Updater`; this module supplies the suite, the quit path and the status dialog. +#[cfg(not(target_arch = "wasm32"))] +use craft_update::updater::{Action, CheckSource, Status, Updater}; use egui::{Align, Layout}; use crate::{PdfCraftApp, theme, widgets}; @@ -14,108 +10,102 @@ use crate::{PdfCraftApp, theme, widgets}; /// Where every PdfCraft release is listed. pub const RELEASES_PAGE: &str = "https://github.com/storytold/pdfcraft/releases"; -/// The latest published release. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Release { - /// Its version tag, such as `v0.2.0`. - pub version: String, - /// Its page on [`RELEASES_PAGE`], where the downloads are. - pub url: String, -} +/// Stands in for the network in tests. +#[cfg(not(target_arch = "wasm32"))] +pub type UpdateSource = CheckSource; -/// Asks for the latest release (blocking; it runs on its own thread). -pub type UpdateSource = Arc Result + Send + Sync>; - -/// Whether release `latest` (a tag such as `v0.2.0`) is newer than version `current` (`0.1.1`). -/// Pre-release and build suffixes are ignored; a version that doesn't parse is never newer. -pub fn is_newer(latest: &str, current: &str) -> bool { - matches!((parse(latest), parse(current)), (Some(l), Some(c)) if l > c) -} - -fn parse(v: &str) -> Option<(u64, u64, u64)> { - let v = v.trim().trim_start_matches(['v', 'V']); - let core = v.split(['-', '+']).next()?; - let mut parts = core.split('.'); - let mut next = |required: bool| match parts.next() { - Some(p) => p.parse::().ok(), - None if required => None, - None => Some(0), - }; - let version = (next(true)?, next(false)?, next(false)?); - parts.next().is_none().then_some(version) -} - -/// Where a check is. -#[derive(Default)] -pub(crate) enum Check { - #[default] - Idle, - #[cfg(not(target_arch = "wasm32"))] - Running(std::sync::mpsc::Receiver>), - Done(Result), -} - -#[derive(Default)] pub(crate) struct Updates { - pub(crate) check: Check, + #[cfg(not(target_arch = "wasm32"))] + updater: Updater, /// The Updates dialog is showing. pub(crate) open: bool, + /// Ask for a newer release at start; the desktop app turns it on, so tests never reach the network. + #[cfg(not(target_arch = "wasm32"))] + startup_check: bool, + #[cfg(not(target_arch = "wasm32"))] + check_requested: bool, +} + +impl Default for Updates { + fn default() -> Self { + Self { + #[cfg(not(target_arch = "wasm32"))] + updater: Updater::new(&craft_update::suite::PDFCRAFT, env!("CARGO_PKG_VERSION")), + open: false, + #[cfg(not(target_arch = "wasm32"))] + startup_check: false, + #[cfg(not(target_arch = "wasm32"))] + check_requested: false, + } + } } impl PdfCraftApp { + /// Whether the app asks for a newer release on its first frame (off until the desktop app turns it on). + #[cfg(not(target_arch = "wasm32"))] + pub fn set_startup_update_check(&mut self, on: bool) { + self.updates.startup_check = on; + } + + /// Replaces the network with `source` (tests). + #[cfg(not(target_arch = "wasm32"))] + pub fn set_update_source(&mut self, source: UpdateSource) { + self.updates.updater = Updater::new(&craft_update::suite::PDFCRAFT, env!("CARGO_PKG_VERSION")).with_source(source); + } + /// Help ▸ Check for updates: ask for the latest release and show the outcome. pub fn check_for_updates(&mut self) { - let Some(source) = self.update_source.clone() else { - self.open_url(RELEASES_PAGE); - return; - }; #[cfg(not(target_arch = "wasm32"))] { self.updates.open = true; - if matches!(self.updates.check, Check::Running(_)) { - return; - } - let (tx, rx) = std::sync::mpsc::channel(); - let ctx = self.ctx.clone(); - std::thread::spawn(move || { - // The receiver may be gone (the app quit): nothing to report to then. - let _ = tx.send(source()); - if let Some(ctx) = ctx { - ctx.request_repaint(); - } - }); - self.updates.check = Check::Running(rx); + self.updates.check_requested = true; } #[cfg(target_arch = "wasm32")] { - let _ = source; self.open_url(RELEASES_PAGE); } } - /// Pick up a finished check (each frame). + /// Starts the checks and picks up their answers (each frame). pub(crate) fn poll_updates(&mut self) { #[cfg(not(target_arch = "wasm32"))] - if let Check::Running(rx) = &self.updates.check { - let result = match rx.try_recv() { - Ok(r) => r, - Err(std::sync::mpsc::TryRecvError::Empty) => return, - Err(std::sync::mpsc::TryRecvError::Disconnected) => Err("the update check stopped unexpectedly".into()), - }; - self.updates.check = Check::Done(result); + { + let Some(ctx) = self.ctx.clone() else { return }; + let updates = &mut self.updates; + if std::mem::take(&mut updates.check_requested) { + updates.updater.check_now(&ctx); + } else if updates.startup_check { + updates.updater.check_at_start(&ctx); + } + updates.updater.tick(&ctx); } } } -/// The Updates dialog. +/// The startup prompt, and the Updates dialog that answers Help ▸ Check for updates. pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + // The restart helper is scheduled before the app closes, so no update starts over unsaved work. + let clean = app.first_dirty().is_none(); + match app.updates.updater.show_if(ctx, clean) { + Action::None => {} + Action::OpenUrl(url) => app.open_url(&url), + Action::Close => { + app.request_quit(ctx); + } + } + // A requested check that found a release is answered by the prompt, not by this dialog. + if app.updates.open && !app.updates.check_requested && matches!(app.updates.updater.status(), Status::Idle) { + app.updates.open = false; + } + } if !app.updates.open { return; } let t = theme::Tokens::get(ctx); let current = env!("CARGO_PKG_VERSION"); let mut close = false; - let mut download: Option = None; let modal = crate::widgets::show_modal(ctx, "updates", |ui| { ui.set_width(420.0); ui.horizontal(|ui| { @@ -123,34 +113,25 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(tl!("Check for updates")).font(theme::semibold(16.0))); }); ui.add_space(8.0); - match &app.updates.check { - Check::Idle => { - ui.label(tl!("No check has run yet.")); - } - #[cfg(not(target_arch = "wasm32"))] - Check::Running(_) => { + #[cfg(not(target_arch = "wasm32"))] + match app.updates.updater.status() { + Status::Idle => { ui.horizontal(|ui| { ui.spinner(); ui.label(tl!("Checking for a newer version…")); }); } - Check::Done(Ok(r)) if is_newer(&r.version, current) => { - let version = r.version.trim_start_matches(['v', 'V']); - ui.label(egui::RichText::new(crate::i18n::fmt(tl!("PdfCraft {v} is available."), &[("v", version)])).strong()); - ui.label( - egui::RichText::new(crate::i18n::fmt( - tl!("You have version {c}. Download the new version from its release page."), - &[("c", current)], - )) - .color(t.text_muted), - ); - download = Some(r.url.clone()); + Status::Checking => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(tl!("Checking for a newer version…")); + }); } - Check::Done(Ok(_)) => { + Status::UpToDate => { ui.label(crate::i18n::fmt(tl!("PdfCraft {c} is up to date."), &[("c", current)])); } - Check::Done(Err(e)) => { - ui.label(crate::i18n::fmt(tl!("Couldn't check for updates: {e}"), &[("e", &e.to_string())])); + Status::Failed(e) => { + ui.label(crate::i18n::fmt(tl!("Couldn't check for updates: {e}"), &[("e", e)])); ui.label( egui::RichText::new(crate::i18n::fmt( tl!("You have version {c}. All releases are listed at {page}."), @@ -160,32 +141,21 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { ); } } + #[cfg(target_arch = "wasm32")] + let _ = (&t, current); ui.add_space(10.0); - ui.label( - egui::RichText::new(tl!("Asks GitHub for the latest release. Nothing is downloaded or installed automatically.")) - .color(t.text_muted) - .small(), - ); - ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if let Some(url) = download.take() { - let get = widgets::pill_button(ui, tl!("Download"), true).clicked(); - let later = widgets::pill_button(ui, tl!("Later"), false).clicked(); - close = get || later; - download = get.then_some(url); - } else if widgets::pill_button(ui, tl!("Close"), true).clicked() { + if widgets::pill_button(ui, tl!("Close"), true).clicked() { close = true; } }); }); if modal.should_close() { close = true; - download = None; } if close { app.updates.open = false; - if let Some(url) = download { - app.open_url(&url); - } + #[cfg(not(target_arch = "wasm32"))] + app.updates.updater.dismiss_status(); } } diff --git a/crates/ui-egui/tests/it/updates.rs b/crates/ui-egui/tests/it/updates.rs index a8ecb31c56..d2eaa7c660 100644 --- a/crates/ui-egui/tests/it/updates.rs +++ b/crates/ui-egui/tests/it/updates.rs @@ -5,18 +5,28 @@ use std::sync::Arc; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_ui_egui::PdfCraftApp; -use pdfcraft_ui_egui::updates::{Release, UpdateSource, is_newer}; +use pdfcraft_ui_egui::updates::UpdateSource; -fn source(answer: Result<&str, &str>) -> UpdateSource { - let answer = answer.map(str::to_string).map_err(str::to_string); - Arc::new(move || answer.clone().map(|v| Release { url: format!("https://github.com/storytold/pdfcraft/releases/tag/{v}"), version: v })) +fn release(tag: &str) -> craft_update::Release { + craft_update::Release { + tag: tag.to_string(), + version: craft_update::parse_version(tag), + html_url: format!("https://github.com/storytold/pdfcraft/releases/tag/{tag}"), + ..Default::default() + } } -fn harness(answer: Result<&str, &str>) -> Harness<'static, PdfCraftApp> { +/// `Ok(Some(tag))` is a newer release, `Ok(None)` is up to date. +fn source(answer: Result, &str>) -> UpdateSource { + let answer = answer.map(|tag| tag.map(release)).map_err(str::to_string); + Arc::new(move || answer.clone()) +} + +fn harness(answer: Result, &str>) -> Harness<'static, PdfCraftApp> { Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.update_source = Some(source(answer)); + app.set_update_source(source(answer)); app }) } @@ -37,34 +47,21 @@ fn settle(h: &mut Harness<'static, PdfCraftApp>) { h.run_steps(2); } -#[test] -fn versions_compare_by_number() { - assert!(is_newer("v0.2.0", "0.1.1")); - assert!(is_newer("v0.1.10", "0.1.9")); - assert!(is_newer("1", "0.9.9")); - assert!(!is_newer("v0.1.1", "0.1.1")); - assert!(!is_newer("v0.1.0", "0.1.1")); - assert!(!is_newer("v0.1.1-beta.2", "0.1.1"), "suffixes are ignored"); - assert!(!is_newer("nightly", "0.1.1"), "a tag that isn't a version is never newer"); - assert!(!is_newer("v1.2.3.4", "0.1.1")); - assert!(!is_newer("v99999999999999999999.0.0", "0.1.1"), "out of range"); -} - #[test] fn a_newer_release_is_offered_for_download() { - let mut h = harness(Ok("v99.0.0")); + let mut h = harness(Ok(Some("v99.0.0"))); h.state_mut().execute("help.check_updates"); settle(&mut h); h.get_by_label_contains("PdfCraft 99.0.0 is available"); - h.get_by_label("Download"); - h.get_by_label("Later").click(); + h.get_by_label("DOWNLOAD"); + h.get_by_label("LATER").click(); h.run_steps(3); assert!(h.query_by_label_contains("is available").is_none(), "Later closes the dialog"); } #[test] fn an_up_to_date_or_failed_check_says_so() { - let mut h = harness(Ok(concat!("v", env!("CARGO_PKG_VERSION")))); + let mut h = harness(Ok(None)); h.state_mut().execute("help.check_updates"); settle(&mut h); h.get_by_label_contains("is up to date"); @@ -78,7 +75,7 @@ fn an_up_to_date_or_failed_check_says_so() { #[test] fn nothing_is_asked_until_the_user_checks() { - // No check at start (the owner's decision): the source is only called from the command. + // The check at start is opt-in (the desktop app turns it on): the source is only called from the command. let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let counted = calls.clone(); let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { @@ -87,9 +84,9 @@ fn nothing_is_asked_until_the_user_checks() { // Settings from a build that had the startup option are ignored. app.restore(r#"{"check_updates_at_start": true}"#); let counted = counted.clone(); - app.update_source = Some(Arc::new(move || { + app.set_update_source(Arc::new(move || { counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(Release { version: "v99.0.0".into(), url: "https://github.com/storytold/pdfcraft/releases/tag/v99.0.0".into() }) + Ok(Some(release("v99.0.0"))) })); app }); @@ -101,3 +98,23 @@ fn nothing_is_asked_until_the_user_checks() { assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); h.get_by_label_contains("PdfCraft 99.0.0 is available"); } + +#[test] +fn the_check_at_start_offers_the_prompt_and_later_dismisses_it() { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.set_update_source(source(Ok(Some("v99.0.0")))); + app.set_startup_update_check(true); + app + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while h.query_by_label_contains("is available").is_none() { + h.run_steps(2); + assert!(std::time::Instant::now() < deadline, "timed out waiting for the startup prompt"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.get_by_label("LATER").click(); + h.run_steps(3); + assert!(h.query_by_label_contains("is available").is_none(), "Later closes the prompt"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8d2f3ecf67..cbbb96da68 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1778,8 +1778,8 @@ milestone = "M3" status = "partial" commands = ["help.check_updates"] tools = [] -evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks", "apps/pdfcraft/src/updates.rs::answers_are_read_and_only_our_release_pages_are_offered"] -notes = "asks GitHub Releases on request and offers the release page. By the owner's decision there is no check at start and no in-place install (Acrobat has both)" +evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::an_up_to_date_or_failed_check_says_so", "crates/ui-egui/tests/it/updates.rs::the_check_at_start_offers_the_prompt_and_later_dismisses_it", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks"] +notes = "asks GitHub Releases through craft-update at start (desktop app only) and on request, offers the release page, and updates and restarts in place where the install type allows it; an install waits while documents are unsaved" [[feature]] id = "view.single-key-accelerators" From 833cd63c8a0aec826595640b356b3213355957bd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 16:14:06 -0400 Subject: [PATCH 02/28] proposal(redact): prevent retention of confidential vector paths behind clipping (F-008) (#4577) Partial vector redaction previously hid covered areas with clipping (`clip_out`, `W/W*`) while retaining the complete original drawing instructions and painting operators in the content stream. This allowed recipients to recover confidential outlined text or other vector drawings from supposedly redacted documents. This change removes vector paths that intersect redaction marks completely and refuses partial clip retention, ensuring no confidential geometry is preserved behind clipping. Report counts are updated to record removed paths. --- crates/redact/src/interp.rs | 33 +++++---------------------------- crates/redact/src/tests.rs | 30 ++++++++++++++++++++++++++++-- parity/acrobat-features.toml | 2 +- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index ffd59a26f6..bd9da5215f 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -884,37 +884,14 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], } let b = bbox.unwrap_or_default(); changed = true; - if scope.covered(b) { - scope.report.paths_removed += 1; - if clips { - // Keep the clipping, drop the painting. - ops.extend(body); - ops.push(Op::new("n", vec![])); - } - } else if clips { - // A clip-and-paint path can't be wrapped in q/Q without losing its clip: - // paint it clipped, then set its clip with a no-op path. - scope.report.paths_clipped += 1; - if let Some(c) = clip_out(scope.rects, &gs.ctm, b) { - let plain: Vec = body.iter().filter(|p| !p.is("W") && !p.is("W*")).cloned().collect(); - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.extend(plain); - ops.push(op); - ops.push(Op::new("Q", vec![])); - } + scope.report.paths_removed += 1; + if scope.covered(b) && clips { + // Keep the clipping, drop the painting. ops.extend(body); ops.push(Op::new("n", vec![])); - } else { - scope.report.paths_clipped += 1; - if let Some(c) = clip_out(scope.rects, &gs.ctm, b) { - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.extend(body); - ops.push(op); - ops.push(Op::new("Q", vec![])); - } } + // Partial vector redaction must not retain underlying drawing commands + // behind clipping (F-008). Refuse partial clip retention and remove the path. continue; } if !path.is_empty() { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 8b94612145..c072d19db7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -281,14 +281,40 @@ fn vectors_are_removed_or_clipped_and_inline_images_go() { let mut doc = one_page(src, "", vec![]); mark(&mut doc, 0, &[[5.0, 5.0, 35.0, 35.0], [45.0, 45.0, 65.0, 65.0]], ""); let r = apply(&mut doc, None).unwrap(); - assert_eq!((r.paths_removed, r.paths_clipped, r.images_removed), (1, 1, 1), "{r:?}"); + assert_eq!((r.paths_removed, r.paths_clipped, r.images_removed), (2, 0, 1), "{r:?}"); let c = content(&doc, 0); assert!(!c.contains("10 10 20 20 re"), "{c}"); - assert!(c.contains("W*\nn\n0 0 300 300 re\nf\nQ"), "the page-size rect is clipped: {c}"); + assert!(!c.contains("300 300 re"), "the intersecting rect is removed: {c}"); assert!(!c.contains("BI"), "{c}"); assert!(c.contains("150 150 m"), "the line elsewhere stays"); } +#[test] +fn partially_covered_vector_paths_do_not_leak_drawing_commands() { + // A vector path with detailed drawing commands (e.g. outlined text or confidential geometry) + // that is only partially covered by a redaction mark must be completely removed, not retained + // behind a clipping path (F-008). + let src = b"0 g 100 100 m 150 150 l 200 100 l h f 10 10 m 20 20 l W b 300 300 m 350 350 l S"; + let mut doc = one_page(src, "", vec![]); + // Redaction mark covers only part of the triangle (100..200, 100..150): + // mark is [120, 120, 160, 160], partially intersecting the triangle. + // Also partially intersect the clip-and-stroke path [10..20, 10..20] with [15, 15, 25, 25]. + mark(&mut doc, 0, &[[120.0, 120.0, 160.0, 160.0], [15.0, 15.0, 25.0, 25.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.paths_removed, 2, "both partially intersected paths removed"); + assert_eq!(r.paths_clipped, 0, "no paths retained via clipping"); + + let c = content(&doc, 0); + // Neither the drawing operators nor the coordinates should leak in the content stream. + assert!(!c.contains("100 100 m"), "underlying geometry must not leak: {c}"); + assert!(!c.contains("150 150 l"), "underlying geometry must not leak: {c}"); + assert!(!c.contains("200 100 l"), "underlying geometry must not leak: {c}"); + assert!(!c.contains("10 10 m"), "partial clip geometry must not leak: {c}"); + assert!(!c.contains("20 20 l"), "partial clip geometry must not leak: {c}"); + // Unrelated non-intersecting path remains untouched. + assert!(c.contains("300 300 m\n350 350 l\nS"), "unaffected vector path must be kept: {c}"); +} + /// A chain of nested forms deeper than the pass can walk was removed from the page, taking /// whatever else it drew with it even when nothing in it was marked. The limit now reaches as /// deep as the renderer and Edit text read (50), so anything a reader can see can be walked; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cbbb96da68..5e1c4a7e75 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5793,7 +5793,7 @@ status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] evidence = ["crates/redact/src/tests.rs::vectors_are_removed_or_clipped_and_inline_images_go"] -notes = "covered paths removed, others clipped; shadings clipped" +notes = "intersecting paths removed to avoid leaking geometry behind clipping; shadings clipped" [[feature]] id = "protect.redact-annots-fields" From cda2d019e3fe19a1229961f91dde260d4a76a599 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 17:37:06 -0400 Subject: [PATCH 03/28] build: pin craft-update to craft-libs main (495e597) (#4721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the update prompt fix that draws its close mark and version pill without glyphs a font may lack, so the prompt no longer takes the app down when a bundled face has no ✕ or →. Part of tracking issue storytold/craft-libs#319 🤖 Generated with Codebuff Co-authored-by: Codebuff --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 947dfb0c82..1acd1ff348 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1184,7 +1184,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index 39d11d1eaf..4d1d5e5f6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0 craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } # internal pdfcraft-geom = { path = "crates/geom", version = "0.14.0" } pdfcraft-filters = { path = "crates/filters", version = "0.14.0" } From 83ba28680f6c780ed7ae553f25555765e4802aeb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 17:54:18 -0400 Subject: [PATCH 04/28] build: pin craft-update to craft-libs main (495e597) (#4728) Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down. Part of tracking issue storytold/craft-libs#319 --- Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1acd1ff348..fd3e1930fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1920,7 +1920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3352,7 +3352,7 @@ dependencies = [ "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3913,7 +3913,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5051,7 +5051,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5601,7 +5601,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5671,7 +5671,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6336,7 +6336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6558,7 +6558,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6944,7 +6944,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7670,7 +7670,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] From ecf62314160a2700d66ddd6c29def50346b07862 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:19:39 +0100 Subject: [PATCH 05/28] refactor(engine): move undo/redo state and labels to history.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/history.rs | 233 +++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 231 +--------------------------------- 2 files changed, 236 insertions(+), 228 deletions(-) create mode 100644 crates/engine/src/history.rs diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs new file mode 100644 index 0000000000..086d98a6ab --- /dev/null +++ b/crates/engine/src/history.rs @@ -0,0 +1,233 @@ +//! Undo/redo: the editing state of a document, its snapshots and their labels. + +use std::sync::Arc; + +use crate::{AppearanceCache, Edit, EditError, Shape}; + +/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap. +pub(crate) const MAX_UNDO: usize = 100; + +/// The passwords that go with one state of a document (protection can change them). +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Keys { + /// What the renderer and inspector open the working file with (the user password). + pub(crate) render: Option, + /// What the editor re-opens a saved file with: the strongest password known (owner if any). + pub(crate) reopen: Option, +} + +/// What an edit can change, and so what the view data must be rebuilt from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Scope { + /// Anything: re-inspect the whole document. + Full, + /// Document-information entries; page content and appearances are unchanged. + Metadata, + /// Only comments: re-read the comment list from the object graph. + Comments, + /// Only form field values (and their widget appearances). + Form, + /// Link annotations; the full inspection is refreshed, but page-content indexes stay valid. + Links, + /// Only page content streams, resources and added content/marks: the page tree, + /// annotations, form fields, links, outline and fonts are unchanged, so the full + /// re-inspection is skipped (seconds on very large files). + Content, +} + +/// Edits during which field scripts run. +pub(crate) fn uses_scripts(edit: &Edit) -> bool { + match edit { + Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true, + Edit::Batch { edits, .. } => edits.iter().any(uses_scripts), + _ => false, + } +} + +pub(crate) fn scope_of(edit: &Edit) -> Scope { + match edit { + Edit::SetInfo { .. } => Scope::Metadata, + // The Attachments list is read from the whole document. + Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, + // A file attachment also changes the Attachments list. + Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, + Edit::AddMeasurement(_) + | Edit::AddAnnotation(_) + | Edit::AddCustomStamp { .. } + | Edit::DeleteAnnotation { .. } + | Edit::SetAnnotationContents { .. } + | Edit::ReplyToAnnotation { .. } + | Edit::SetAnnotationStatus { .. } + | Edit::MarkAnnotation { .. } + | Edit::LockAnnotation { .. } + | Edit::ReplaceText { .. } + | Edit::EraseInk { .. } + | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } + | Edit::ResizeAnnotation { .. } + | Edit::StyleAnnotation { .. } + | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } + | Edit::SetAnnotationInfo { .. } => Scope::Comments, + Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, + // Page content, resources and added content/marks only: the page tree, annotations, + // fields, links and document metadata are untouched (see `refresh_content`). + Edit::MoveObjects { .. } + | Edit::EditTextLine { .. } + | Edit::EditTextBlock { .. } + | Edit::EditPageImage { .. } + | Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + | Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::RemoveMarks { .. } => Scope::Content, + Edit::Batch { edits, .. } => { + let mut scopes = edits.iter().map(scope_of); + let first = scopes.next().unwrap_or(Scope::Full); + if scopes.all(|s| s == first) { first } else { Scope::Full } + } + _ => Scope::Full, + } +} + +/// One undo/redo step: its label, the document state, its passwords, and what it changed. +pub(crate) type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope); + +/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted). +#[derive(Clone)] +pub(crate) struct Editor { + pub(crate) cos: pdfcraft_cos::Document, + /// The file as last opened or saved. Incremental saves append to it, and Revert opens it. + /// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it. + pub(crate) saved: Arc>, + /// Passwords that open [`Self::saved`]. + pub(crate) saved_keys: Keys, + /// [`Self::saved`] was never written where the document belongs: a new document (combine, + /// extract, create) or one restored from recovery. It stays unsaved until a save, whatever + /// undo brings back, or closing it would lose the whole document without asking. + pub(crate) unsaved_file: bool, + pub(crate) undo: Vec, + pub(crate) redo: Vec, + pub(crate) keys: Keys, + /// Display appearances built for annotations without any, reused while no annotation edit + /// runs (content edits leave every annotation dictionary alone). Cleared on any refresh + /// that may change annotations; see [`AppearanceCache`]. + pub(crate) appearances: AppearanceCache, + /// Display appearances drawn for form fields the file leaves to the viewer (#3470), reused + /// for each widget while the objects it was drawn from are unchanged; see + /// [`pdfcraft_forms::FieldAppearanceCache`]. + pub(crate) field_appearances: pdfcraft_forms::FieldAppearanceCache, + /// The text blocks last computed for one page, reused while the document is unchanged. + pub(crate) text_blocks: TextBlocksCache, +} + +/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read +/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not +/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns +/// with undo, so a stale entry never matches, also after the document has been edited. +#[derive(Default)] +pub(crate) struct TextBlocksCache(std::sync::Mutex>); + +/// The document state, the page (0-based) and its text blocks. +type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc>); + +impl Clone for TextBlocksCache { + fn clone(&self) -> Self { + // A poisoned cache only loses the cached blocks. + Self(std::sync::Mutex::new(self.0.lock().ok().and_then(|c| c.clone()))) + } +} + +impl Editor { + /// The text blocks on `page` (0-based), from the cache when the document hasn't changed. + pub(crate) fn text_blocks(&self, page: usize) -> Result>, EditError> { + if let Ok(cache) = self.text_blocks.0.lock() + && let Some((state, p, blocks)) = cache.as_ref() + && *p == page + && state.matches(&self.cos) + { + return Ok(blocks.clone()); + } + let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?); + if let Ok(mut cache) = self.text_blocks.0.lock() { + *cache = Some((self.cos.read_state(), page, blocks.clone())); + } + Ok(blocks) + } +} + +/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts: +/// a whole English wording, and the one value the document itself supplies for the wording's hole +/// (a file name, a field name). An interface in another language looks the wording up and says the +/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so +/// the two cannot drift apart. +#[derive(Clone, Debug, PartialEq)] +pub struct Label { + /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`]. + pub wording: &'static str, + /// What the document supplies, when the wording has a hole for it. + pub value: Option, + text: String, +} + +impl Label { + /// A wording with nothing to fill in. + pub fn of(wording: &'static str) -> Self { + Self { wording, value: None, text: wording.to_string() } + } + + /// A wording whose hole the document fills in. + pub fn with(wording: &'static str, value: impl Into) -> Self { + let mut label = Self { wording, value: Some(value.into()), text: String::new() }; + label.text = label.said(wording); + label + } + + /// The label read with this wording: the interface's translation of [`wording`](Self::wording), + /// or the English wording itself. What the document supplies goes in as it stands and is not + /// read again, so a name with braces in it stays whole. + pub fn said(&self, wording: &str) -> String { + let Some(value) = &self.value else { return wording.to_owned() }; + // `{` and `}` are ASCII, so these are char boundaries. + match (wording.find('{'), wording.find('}')) { + (Some(open), Some(close)) if open < close => { + let mut said = String::with_capacity(wording.len() + value.len()); + said.push_str(&wording[..open]); + said.push_str(value); + said.push_str(&wording[close + 1..]); + said + } + // A translation that lost its hole is said as it stands rather than losing the value. + _ => wording.to_owned(), + } + } + + /// The label as English reads it. + pub fn as_str(&self) -> &str { + &self.text + } +} + +impl std::fmt::Display for Label { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.text) + } +} + +/// The passwords after `edit`, if it changes them. +pub(crate) fn keys_after(edit: &Edit) -> Option { + match edit { + Edit::Protect(p) => { + Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) }) + } + Edit::RemoveProtection => Some(Keys::default()), + Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after), + _ => None, + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 224318f1d7..90c698dfe3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -25,6 +25,7 @@ mod copy_image; pub mod dates; pub mod export; pub mod field_table; +mod history; pub mod js; pub mod links; pub mod ocr; @@ -226,6 +227,8 @@ fn record_link_scan() { }); } +pub use history::Label; +use history::{Editor, Keys, MAX_UNDO, Scope, TextBlocksCache, keys_after, scope_of, uses_scripts}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; @@ -233,168 +236,10 @@ use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderC #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct DocId(pub u64); -/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap. -const MAX_UNDO: usize = 100; - /// The highest resolution a document that allows only low-resolution printing (Table 22, bit 12 /// clear) is printed at. const LOW_RESOLUTION_PRINT_DPI: f64 = 150.0; -/// The passwords that go with one state of a document (protection can change them). -#[derive(Clone, Debug, Default, PartialEq)] -struct Keys { - /// What the renderer and inspector open the working file with (the user password). - render: Option, - /// What the editor re-opens a saved file with: the strongest password known (owner if any). - reopen: Option, -} - -/// What an edit can change, and so what the view data must be rebuilt from. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Scope { - /// Anything: re-inspect the whole document. - Full, - /// Document-information entries; page content and appearances are unchanged. - Metadata, - /// Only comments: re-read the comment list from the object graph. - Comments, - /// Only form field values (and their widget appearances). - Form, - /// Link annotations; the full inspection is refreshed, but page-content indexes stay valid. - Links, - /// Only page content streams, resources and added content/marks: the page tree, - /// annotations, form fields, links, outline and fonts are unchanged, so the full - /// re-inspection is skipped (seconds on very large files). - Content, -} - -/// Edits during which field scripts run. -fn uses_scripts(edit: &Edit) -> bool { - match edit { - Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true, - Edit::Batch { edits, .. } => edits.iter().any(uses_scripts), - _ => false, - } -} - -fn scope_of(edit: &Edit) -> Scope { - match edit { - Edit::SetInfo { .. } => Scope::Metadata, - // The Attachments list is read from the whole document. - Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, - // A file attachment also changes the Attachments list. - Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, - Edit::AddMeasurement(_) - | Edit::AddAnnotation(_) - | Edit::AddCustomStamp { .. } - | Edit::DeleteAnnotation { .. } - | Edit::SetAnnotationContents { .. } - | Edit::ReplyToAnnotation { .. } - | Edit::SetAnnotationStatus { .. } - | Edit::MarkAnnotation { .. } - | Edit::LockAnnotation { .. } - | Edit::ReplaceText { .. } - | Edit::EraseInk { .. } - | Edit::MoveAnnotation { .. } - | Edit::ReshapeAnnotation { .. } - | Edit::ResizeAnnotation { .. } - | Edit::StyleAnnotation { .. } - | Edit::FillAnnotation { .. } - | Edit::FontSizeAnnotation { .. } - | Edit::SetAnnotationInfo { .. } => Scope::Comments, - Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, - // Page content, resources and added content/marks only: the page tree, annotations, - // fields, links and document metadata are untouched (see `refresh_content`). - Edit::MoveObjects { .. } - | Edit::EditTextLine { .. } - | Edit::EditTextBlock { .. } - | Edit::EditPageImage { .. } - | Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - | Edit::AddHeaderFooter { .. } - | Edit::AddWatermark { .. } - | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } => Scope::Content, - Edit::Batch { edits, .. } => { - let mut scopes = edits.iter().map(scope_of); - let first = scopes.next().unwrap_or(Scope::Full); - if scopes.all(|s| s == first) { first } else { Scope::Full } - } - _ => Scope::Full, - } -} - -/// One undo/redo step: its label, the document state, its passwords, and what it changed. -type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope); - -/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted). -#[derive(Clone)] -struct Editor { - cos: pdfcraft_cos::Document, - /// The file as last opened or saved. Incremental saves append to it, and Revert opens it. - /// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it. - saved: Arc>, - /// Passwords that open [`Self::saved`]. - saved_keys: Keys, - /// [`Self::saved`] was never written where the document belongs: a new document (combine, - /// extract, create) or one restored from recovery. It stays unsaved until a save, whatever - /// undo brings back, or closing it would lose the whole document without asking. - unsaved_file: bool, - undo: Vec, - redo: Vec, - keys: Keys, - /// Display appearances built for annotations without any, reused while no annotation edit - /// runs (content edits leave every annotation dictionary alone). Cleared on any refresh - /// that may change annotations; see [`AppearanceCache`]. - appearances: AppearanceCache, - /// Display appearances drawn for form fields the file leaves to the viewer (#3470), reused - /// for each widget while the objects it was drawn from are unchanged; see - /// [`pdfcraft_forms::FieldAppearanceCache`]. - field_appearances: pdfcraft_forms::FieldAppearanceCache, - /// The text blocks last computed for one page, reused while the document is unchanged. - text_blocks: TextBlocksCache, -} - -/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read -/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not -/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns -/// with undo, so a stale entry never matches, also after the document has been edited. -#[derive(Default)] -struct TextBlocksCache(std::sync::Mutex>); - -/// The document state, the page (0-based) and its text blocks. -type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc>); - -impl Clone for TextBlocksCache { - fn clone(&self) -> Self { - // A poisoned cache only loses the cached blocks. - Self(std::sync::Mutex::new(self.0.lock().ok().and_then(|c| c.clone()))) - } -} - -impl Editor { - /// The text blocks on `page` (0-based), from the cache when the document hasn't changed. - fn text_blocks(&self, page: usize) -> Result>, EditError> { - if let Ok(cache) = self.text_blocks.0.lock() - && let Some((state, p, blocks)) = cache.as_ref() - && *p == page - && state.matches(&self.cos) - { - return Ok(blocks.clone()); - } - let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?); - if let Ok(mut cache) = self.text_blocks.0.lock() { - *cache = Some((self.cos.read_state(), page, blocks.clone())); - } - Ok(blocks) - } -} - pub struct Document { pub id: DocId, pub name: String, @@ -1918,64 +1763,6 @@ pub enum Edit { }, } -/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts: -/// a whole English wording, and the one value the document itself supplies for the wording's hole -/// (a file name, a field name). An interface in another language looks the wording up and says the -/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so -/// the two cannot drift apart. -#[derive(Clone, Debug, PartialEq)] -pub struct Label { - /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`]. - pub wording: &'static str, - /// What the document supplies, when the wording has a hole for it. - pub value: Option, - text: String, -} - -impl Label { - /// A wording with nothing to fill in. - pub fn of(wording: &'static str) -> Self { - Self { wording, value: None, text: wording.to_string() } - } - - /// A wording whose hole the document fills in. - pub fn with(wording: &'static str, value: impl Into) -> Self { - let mut label = Self { wording, value: Some(value.into()), text: String::new() }; - label.text = label.said(wording); - label - } - - /// The label read with this wording: the interface's translation of [`wording`](Self::wording), - /// or the English wording itself. What the document supplies goes in as it stands and is not - /// read again, so a name with braces in it stays whole. - pub fn said(&self, wording: &str) -> String { - let Some(value) = &self.value else { return wording.to_owned() }; - // `{` and `}` are ASCII, so these are char boundaries. - match (wording.find('{'), wording.find('}')) { - (Some(open), Some(close)) if open < close => { - let mut said = String::with_capacity(wording.len() + value.len()); - said.push_str(&wording[..open]); - said.push_str(value); - said.push_str(&wording[close + 1..]); - said - } - // A translation that lost its hole is said as it stands rather than losing the value. - _ => wording.to_owned(), - } - } - - /// The label as English reads it. - pub fn as_str(&self) -> &str { - &self.text - } -} - -impl std::fmt::Display for Label { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.text) - } -} - /// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not /// here: its wording is only the hole, because a batch carries its name as text (see /// [`BATCH_LABELS`]). @@ -3051,18 +2838,6 @@ fn parse_ymd(d: &str) -> Option<(i64, u32, u32)> { Some((d.get(0..4)?.parse().ok()?, d.get(4..6)?.parse().ok()?, d.get(6..8)?.parse().ok()?)) } -/// The passwords after `edit`, if it changes them. -fn keys_after(edit: &Edit) -> Option { - match edit { - Edit::Protect(p) => { - Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) }) - } - Edit::RemoveProtection => Some(Keys::default()), - Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after), - _ => None, - } -} - /// The recovered user password of an R2–R4 file as text for the renderer, which tries a password /// as its UTF-8 bytes and then in PDFDocEncoding: UTF-8 bytes (as some writers store passwords /// PDFDocEncoding can't hold, such as "şifre") stay UTF-8, and other bytes are read as From 7e72b583edbfc942802b79aff453a3f8193da920 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:19:53 +0100 Subject: [PATCH 06/28] refactor(engine): move EditError and SourceProblem to error.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/error.rs | 80 ++++++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 79 +------------------------------------ 2 files changed, 82 insertions(+), 77 deletions(-) create mode 100644 crates/engine/src/error.rs diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs new file mode 100644 index 0000000000..5ee915fcdf --- /dev/null +++ b/crates/engine/src/error.rs @@ -0,0 +1,80 @@ +//! The errors editing and combining report. + +use crate::measure; + +/// Why a file can't be a source for Combine Files (or Insert / Replace pages). +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum SourceProblem { + #[error("it is password-protected")] + Password, + #[error("the password is wrong")] + WrongPassword, + #[error("its security settings don't allow copying pages")] + NotPermitted, + #[error("{0}")] + Unreadable(String), +} + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum EditError { + #[error("no such document")] + NoDocument, + #[error("this document can't be edited: {0}")] + ReadOnly(String), + #[error("the document's security settings don't allow {0}; open it with the owner password to make this change")] + NotPermitted(&'static str), + #[error("{0}")] + Organize(#[from] pdfcraft_organize::OrganizeError), + #[error("{0}")] + Bookmark(#[from] pdfcraft_organize::OutlineError), + #[error("{0}")] + Comment(#[from] pdfcraft_annot::AnnotError), + #[error(transparent)] + Measure(#[from] measure::MeasureError), + #[error("{0}")] + Protection(String), + #[error("the password is incorrect")] + WrongPassword, + #[error("{0}")] + Form(#[from] pdfcraft_forms::FormError), + #[error(transparent)] + Redact(#[from] pdfcraft_redact::RedactError), + #[error("{0}")] + Print(String), + #[error("{0}")] + Accessibility(String), + #[error(transparent)] + Data(#[from] pdfcraft_xfdf::DataError), + #[error("{0}")] + Edit(#[from] pdfcraft_edit::EditError), + #[error("{0}")] + Create(#[from] pdfcraft_create::CreateError), + #[error("the edited document could not be written: {0}")] + Write(String), + #[error("the edited document could not be reopened: {0}")] + Reopen(String), + #[error("couldn't use {0}")] + Source(String), + #[error("nothing to undo")] + NothingToUndo, + #[error("nothing to redo")] + NothingToRedo, + #[error("{0}")] + Sign(String), + #[error("{0}")] + Optimize(String), + #[error("cancelled")] + Cancelled, + #[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")] + SignedRewrite(String), + #[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")] + Signed, + #[error("{0}")] + Invalid(String), +} + +impl From for EditError { + fn from(e: pdfcraft_sign::SignError) -> Self { + EditError::Sign(e.to_string()) + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 90c698dfe3..d34decd658 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod commands; pub mod compare; mod copy_image; pub mod dates; +mod error; pub mod export; pub mod field_table; mod history; @@ -227,6 +228,7 @@ fn record_link_scan() { }); } +pub use error::{EditError, SourceProblem}; pub use history::Label; use history::{Editor, Keys, MAX_UNDO, Scope, TextBlocksCache, keys_after, scope_of, uses_scripts}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; @@ -2889,19 +2891,6 @@ fn open_source_with(name: &str, bytes: &Arc>, password: Option<&str>) -> source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) } -/// Why a file can't be a source for Combine Files (or Insert / Replace pages). -#[derive(Debug, Clone, PartialEq, thiserror::Error)] -pub enum SourceProblem { - #[error("it is password-protected")] - Password, - #[error("the password is wrong")] - WrongPassword, - #[error("its security settings don't allow copying pages")] - NotPermitted, - #[error("{0}")] - Unreadable(String), -} - fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), @@ -2933,70 +2922,6 @@ fn plural(one: &'static str, many: &'static str, n: usize) -> Label { Label::of(if n == 1 { one } else { many }) } -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum EditError { - #[error("no such document")] - NoDocument, - #[error("this document can't be edited: {0}")] - ReadOnly(String), - #[error("the document's security settings don't allow {0}; open it with the owner password to make this change")] - NotPermitted(&'static str), - #[error("{0}")] - Organize(#[from] pdfcraft_organize::OrganizeError), - #[error("{0}")] - Bookmark(#[from] pdfcraft_organize::OutlineError), - #[error("{0}")] - Comment(#[from] pdfcraft_annot::AnnotError), - #[error(transparent)] - Measure(#[from] measure::MeasureError), - #[error("{0}")] - Protection(String), - #[error("the password is incorrect")] - WrongPassword, - #[error("{0}")] - Form(#[from] pdfcraft_forms::FormError), - #[error(transparent)] - Redact(#[from] pdfcraft_redact::RedactError), - #[error("{0}")] - Print(String), - #[error("{0}")] - Accessibility(String), - #[error(transparent)] - Data(#[from] pdfcraft_xfdf::DataError), - #[error("{0}")] - Edit(#[from] pdfcraft_edit::EditError), - #[error("{0}")] - Create(#[from] pdfcraft_create::CreateError), - #[error("the edited document could not be written: {0}")] - Write(String), - #[error("the edited document could not be reopened: {0}")] - Reopen(String), - #[error("couldn't use {0}")] - Source(String), - #[error("nothing to undo")] - NothingToUndo, - #[error("nothing to redo")] - NothingToRedo, - #[error("{0}")] - Sign(String), - #[error("{0}")] - Optimize(String), - #[error("cancelled")] - Cancelled, - #[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")] - SignedRewrite(String), - #[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")] - Signed, - #[error("{0}")] - Invalid(String), -} - -impl From for EditError { - fn from(e: pdfcraft_sign::SignError) -> Self { - EditError::Sign(e.to_string()) - } -} - #[derive(Default)] pub struct Session { docs: Vec, From 58e7e367172b08f6951cac4cf7b10010e8c708fd Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:20:51 +0100 Subject: [PATCH 07/28] refactor(engine): move the Edit enum, its labels, permission check and runner to edit.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/edit.rs | 1577 ++++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 1590 +------------------------------------ 2 files changed, 1590 insertions(+), 1577 deletions(-) create mode 100644 crates/engine/src/edit.rs diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs new file mode 100644 index 0000000000..a29e4a870b --- /dev/null +++ b/crates/engine/src/edit.rs @@ -0,0 +1,1577 @@ +//! The edits a document takes: what each is called, whether its permissions allow it, and running it. + +use std::sync::Arc; + +use crate::{ + AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label, + LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState, + Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture, + image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, +}; + +/// Edits that can be applied to a document. Page indices are 0-based. +#[derive(Clone, Debug, PartialEq)] +pub enum Edit { + RotatePages { + pages: Vec, + degrees: i64, + }, + DeletePages { + pages: Vec, + }, + MovePages { + pages: Vec, + to: usize, + }, + /// `pages` reversed among the positions they occupy. + ReversePages { + pages: Vec, + }, + InsertBlankPage { + at: usize, + width: f64, + height: f64, + }, + /// Copies of `pages` inserted at `at` (an index in the page order as it is now), or after + /// the last of them when `at` is `None`. + DuplicatePages { + pages: Vec, + at: Option, + }, + /// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the + /// pages' links, comments and bookmarks stay. + ReplacePages { + pages: Vec, + name: String, + bytes: Arc>, + src_pages: Vec, + }, + /// Set Page Boxes / Crop: set (or reset) one box on `pages`. + SetPageBox { + pages: Vec, + which: PageBox, + spec: BoxSpec, + }, + /// Set or remove the page transition and display time of pages (ISO 32000-2 §12.4.4), + /// Organize ▸ Page Transitions. + SetTransition { + pages: Vec, + spec: TransitionSpec, + }, + SetInfo { + key: String, + value: String, + }, + /// Insert pages from another PDF (all pages when `pages` is `None`) at position `at`. + InsertPagesFrom { + name: String, + bytes: Arc>, + pages: Option>, + at: usize, + /// How a form field whose name the document already uses is kept separate. + field_conflicts: pdfcraft_organize::FieldConflicts, + }, + /// Add a bookmark to `page` as child `index` of the bookmark at `parent` (`[]` = top level). + /// It goes to the user-space point (`left`, `top`) of the page at the reader's zoom, and + /// either may be `None` to keep the reader's position that way (both `None` is the page top). + /// A coordinate that is not finite fails the edit. + AddBookmark { + parent: Vec, + index: usize, + title: String, + page: usize, + left: Option, + top: Option, + }, + RenameBookmark { + path: Vec, + title: String, + }, + /// Delete a bookmark and the bookmarks under it. + DeleteBookmark { + path: Vec, + }, + /// Move a bookmark to child `index` of `to_parent` (indices after removing it). + MoveBookmark { + from: Vec, + to_parent: Vec, + index: usize, + }, + /// Point a bookmark at another page. + SetBookmarkPage { + path: Vec, + page: usize, + }, + /// Colour a bookmark's title (`/C`); `None` or black is the default black. + SetBookmarkColor { + path: Vec, + color: Option<[f64; 3]>, + }, + /// Make a bookmark's title bold or italic or not (`/F`); `None` keeps that one. + SetBookmarkStyle { + path: Vec, + bold: Option, + italic: Option, + }, + /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first + /// "Untitled" bookmark (New Bookmarks from Structure). + BookmarksFromStructure, + /// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels. + NumberPages { + from: usize, + to: usize, + style: pdfcraft_organize::LabelStyle, + prefix: String, + first: u32, + }, + /// Add a calibrated distance, perimeter or area annotation. + AddMeasurement(measure::NewMeasurement), + /// Store a drawing scale for a rectangular viewport (PDF user space). + SetMeasurementScale { + page: usize, + bbox: [f64; 4], + name: String, + scale: measure::Scale, + }, + /// Add a comment (sticky note, highlight, shape, drawing, text box…). + AddAnnotation(NewAnnotation), + /// A custom stamp from a picture file (a PDF page or an image) on `page`. A zero-size + /// `rect` is a point: the stamp is centred there at its natural size (at most 200 pt). + /// Any other `rect` is a box: the picture is scaled to fit inside it, keeping its shape, + /// and centred in it (#4077). + AddCustomStamp { + page: usize, + rect: [f64; 4], + name: String, + file: MarkFile, + author: String, + }, + /// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies. + DeleteAnnotation { + page: usize, + index: usize, + }, + SetAnnotationContents { + page: usize, + index: usize, + text: String, + }, + ReplyToAnnotation { + page: usize, + index: usize, + text: String, + author: String, + }, + /// Acrobat's "Set status" (a state reply by `author`). + SetAnnotationStatus { + page: usize, + index: usize, + state: ReviewState, + author: String, + }, + /// The Eraser: rub out the parts of drawing `(page, index)` near `path` (user space). + EraseInk { + page: usize, + index: usize, + path: Vec<[f64; 2]>, + radius: f64, + }, + /// Replace Text: strike out `quads` and add a grouped caret holding `text`. + ReplaceText { + page: usize, + quads: Vec<[f64; 8]>, + text: String, + author: String, + strike: Style, + caret: Style, + }, + /// Acrobat's "Mark with checkmark" (a hidden `/StateModel /Marked` reply by `author`). + MarkAnnotation { + page: usize, + index: usize, + marked: bool, + author: String, + }, + /// Properties ▸ Locked. + LockAnnotation { + page: usize, + index: usize, + locked: bool, + }, + MoveAnnotation { + page: usize, + index: usize, + dx: f64, + dy: f64, + }, + /// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's + /// two ends, the others' corners). + ReshapeAnnotation { + page: usize, + index: usize, + points: Vec<[f64; 2]>, + }, + /// Resize a rectangle, oval or text box. + ResizeAnnotation { + page: usize, + index: usize, + rect: [f64; 4], + }, + StyleAnnotation { + page: usize, + index: usize, + color: Option, + opacity: Option, + width: Option, + /// Line or polyline: two endings. Callout: one. `None` leaves `/LE` unchanged. + endings: Option>, + }, + /// Fill a rectangle, oval or polygon comment, or remove its fill (`None`). + FillAnnotation { + page: usize, + index: usize, + fill: Option, + }, + /// Change the font size (pt) of a text box, typewriter text or callout. + FontSizeAnnotation { + page: usize, + index: usize, + size: f64, + }, + /// Comment properties ▸ General / note icon. + SetAnnotationInfo { + page: usize, + index: usize, + author: Option, + subject: Option, + icon: Option, + }, + /// Fill in a form field. + SetFieldValue { + name: String, + value: FieldValue, + }, + /// Clear form: the named fields (all when `None`) back to their defaults. + ResetForm { + names: Option>, + }, + /// Prepare a form: add a field on a page (`rect` in PDF user space); `name` defaults to + /// Acrobat's next free name ("Text1", "Check Box2", …). + AddField { + page: usize, + rect: [f64; 4], + kind: NewField, + name: Option, + }, + /// Field Properties ▸ General / Options. + SetFieldProps { + name: String, + props: Box, + }, + /// An image field (or any button): show this image file (PNG, JPEG, TIFF, GIF, BMP). + SetFieldImage { + name: String, + image: Arc>, + }, + /// Duplicate a field onto other pages (same field, shared value). + DuplicateField { + name: String, + pages: Vec, + }, + DeleteField { + name: String, + }, + /// Page properties ▸ Tab order, for pages (0-based). + SetTabOrder { + pages: Vec, + order: TabOrder, + }, + /// Document Properties ▸ Initial View (and the language and binding). + SetInitialView(Box), + /// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under + /// `name`, made unique; `mime` is the embedded file's `/Subtype` when known. + AddAttachment { + name: String, + bytes: Vec, + description: Option, + mime: Option, + }, + /// Remove the document-level attachment whose name-tree key is `key`. + DeleteAttachment { + key: String, + }, + /// Build a tagged structure tree for an untagged, text-only document. + AutoTagText, + /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is + /// the figure element's object number (from `Document::figures`). + SetAltText { + figure: u32, + alt: Option, + }, + /// Add `/Form` tags and object references for all widgets in an untagged AcroForm. + AutoTagFormFields, + /// Change one property on a reachable indirect structure tag. + SetTagProperty { + object: pdfcraft_cos::ObjRef, + property: a11y::TagProperty, + value: Option, + }, + /// Move a structure tag before or after a direct sibling in reading order. + MoveTag { + object: pdfcraft_cos::ObjRef, + anchor: pdfcraft_cos::ObjRef, + placement: a11y::TagPlacement, + }, + /// Add alternate text ▸ Decorative figure: the figure's content becomes an artifact. + MarkDecorative { + figure: u32, + }, + /// Tags panel ▸ Mark as artifact: retire a leaf tag and mark its content as an artifact. + MarkTagArtifact { + object: pdfcraft_cos::ObjRef, + }, + /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level + /// script `name`. + SetDocumentScript { + name: String, + script: Option, + }, + /// Field Properties ▸ a custom script: set or remove field `name`'s JavaScript for `event` + /// (keystroke, format, validate, calculate or mouse_up). + SetFieldScript { + name: String, + event: String, + script: Option, + }, + /// Standards ▸ Save as PDF/A: fix what can be fixed for `level` (metadata, output intent, + /// forbidden actions, annotation flags, …). + ConvertPdfA { + level: pdfcraft_preflight::Level, + }, + /// Remove what identifies the document as meeting `standard` (PDF/A or PDF/X). + RemoveStandardId { + standard: Standard, + }, + /// What a script (button or console) changed in form fields: values, read-only, required + /// and visibility. + ApplyScriptChanges { + changes: Vec, + }, + /// An XFA form's scripted event (a button's `click`, say) on the object at `som`: what the + /// script changes (values, rows, visibility) is applied and the form laid out again. + XfaEvent { + som: String, + activity: String, + }, + /// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the + /// image (from [`ocr::OcrJob::run`]). + AddOcrText { + page: usize, + words: Vec, + }, + /// Edit a PDF ▸ Edit text: replace the text of line `line` (from `Document::text_lines`) on + /// `page`, in its own font when it can show it, else in Helvetica. + EditTextLine { + page: usize, + line: usize, + text: String, + }, + /// Edit a PDF ▸ an existing image on `page` (index from `Document::page_images`). + EditPageImage { + page: usize, + index: usize, + change: ImageEdit, + }, + /// Move mixed existing and added objects together. References resolve before any change; + /// offset is a user-space vector. No retyping, reflow or font substitution is involved. + MoveObjects { + page: usize, + objects: Vec, + offset: [f64; 2], + }, + /// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`), + /// rewrapped to the box's width. + EditTextBlock { + page: usize, + block: usize, + text: String, + /// Formatting changes (font, size, colour, alignment); default keeps the paragraph's. + style: pdfcraft_edit::BlockStyle, + }, + /// An explicit open-licensed full font program, completing subsets or selected by a user. + RegisterTextFont { + page: usize, + bytes: Arc>, + }, + /// Order tabs manually: move a field one place earlier or later on its page. + MoveInTabOrder { + name: String, + earlier: bool, + }, + /// Set the calculation order: these calculated fields first, in this order, then the others + /// in their current order (see [`Document::calculation_order`]). The form recalculates. + SetCalculationOrder { + names: Vec, + }, + /// Add a header and footer (with `replace`, existing ones on those pages go first). + AddHeaderFooter { + pages: Vec, + settings: HeaderFooter, + replace: bool, + }, + /// `file`: a picture from a file (an image, or a page of a PDF) instead of text. + AddWatermark { + pages: Vec, + settings: Watermark, + replace: bool, + file: Option, + }, + /// `file`: a picture from a file instead of a colour. + AddBackground { + pages: Vec, + settings: Background, + replace: bool, + file: Option, + }, + /// Remove every mark of a kind from every page. + RemoveMarks { + kind: MarkKind, + }, + /// Edit a PDF ▸ Add content ▸ Text (`text.rect` in display space). + AddText { + page: usize, + text: AddedText, + }, + /// Edit a PDF ▸ Add content ▸ Image: an image file placed at `rect` (display space), or at + /// its natural size centred on the page (shrunk to fit). + AddImage { + page: usize, + rect: Option<[f64; 4]>, + name: String, + bytes: Arc>, + }, + /// Move, resize, retype or reformat an added item (`index` among the page's added items). + UpdateContent { + page: usize, + index: usize, + content: AddedContent, + }, + DeleteContent { + page: usize, + index: usize, + }, + /// Replace an added image with another file (keeping its box and transforms). + ReplaceImage { + page: usize, + index: usize, + name: String, + bytes: Arc>, + }, + /// Edit ▸ Paste: an added item copied in this document or another, as it was: text with its + /// style, an image in its box (display space) with its turns, flips and crop. An image's + /// picture is `file` (from [`Document::added_image_file`]): the copied image object is reused + /// while it is still that picture in this document, else one an added image here already draws + /// it from, else the file is embedded. + PasteContent { + page: usize, + content: AddedContent, + file: Option<(String, Arc>)>, + }, + /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. + ApplyRedactions { + pages: Option>, + }, + /// Remove redaction marks without applying them. + ClearRedactions, + /// Edit a PDF ▸ Link: a new link over `rect` (user space). + AddLink { + page: usize, + rect: [f64; 4], + action: LinkAction, + style: LinkStyle, + }, + /// Link Properties (`index` in the page's `/Annots`). + SetLink { + page: usize, + index: usize, + rect: Option<[f64; 4]>, + action: Option, + style: Option, + }, + DeleteLink { + page: usize, + index: usize, + }, + /// Remove all links (on `pages`, or everywhere). + RemoveLinks { + pages: Option>, + }, + /// Create links from URLs in the text: (page, line rects in user space, URI). + AddLinks { + links: Vec<(usize, Vec<[f64; 4]>, String)>, + style: LinkStyle, + }, + /// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text). + ImportData { + name: String, + bytes: Arc>, + }, + /// Remove Hidden Information: the chosen categories. + RemoveHidden { + which: Vec, + }, + /// Sanitize Document: every category, then a full rewrite on save. + Sanitize, + /// An XFA form made an ordinary (static) PDF form: the XFA packets, `/NeedsRendering` and + /// PdfCraft's private keys go, the pages as laid out and the fields with their values + /// stay, so every viewer shows the same form and none lays it out again. Its scripts no + /// longer run. + XfaToStatic, + /// Flatten comments and/or form fields on every page into page content. + Flatten { + comments: bool, + fields: bool, + }, + /// Bake Fill & Sign text, marks and signatures into the page. Other comments and fields stay. + /// A no-op (no undo step) when the document has none. + FlattenFillSign, + /// Protect with passwords and permissions (written by the next save, which is a full rewrite). + Protect(Protection), + /// Remove password security (needs the owner password). + RemoveProtection, + /// Several edits applied as one undoable step (all or nothing). + Batch { + label: String, + edits: Vec, + }, +} + +/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not +/// here: its wording is only the hole, because a batch carries its name as text (see +/// [`BATCH_LABELS`]). +pub const EDIT_LABELS: &[&str] = &[ + "Rotate page", + "Change tag properties", + "Rotate pages", + "Delete page", + "Delete pages", + "Move page", + "Move pages", + "Reverse pages", + "Insert blank page", + "Duplicate page", + "Duplicate pages", + "Replace page", + "Replace pages", + "Crop page", + "Crop pages", + "Set page boxes", + "Set page transitions", + "Change Title", + "Change Author", + "Change Subject", + "Change Keywords", + "Change {key}", + "Insert pages from {name}", + "Insert pages from {name} — 1 field renamed", + "Insert pages from {name} — fields renamed", + "Attach {name}", + "Delete attachment {key}", + "Add bookmark", + "Rename bookmark", + "Delete bookmark", + "Move bookmark", + "Set bookmark destination", + "Colour", + "Style", + "New bookmarks from structure", + "Number pages", + "Measure distance", + "Measure perimeter", + "Measure area", + "Set measurement scale", + "Add sticky note", + "Add highlight", + "Add underline", + "Add strikethrough", + "Add squiggly underline", + "Add rectangle", + "Add oval", + "Add arrow", + "Add line", + "Add drawing", + "Add text box", + "Add checkmark", + "Add cross", + "Add dot", + "Add signature", + "Add redaction mark", + "Add cloud", + "Add polygon", + "Add connected lines", + "Add callout", + "Add inserted text", + "Add file attachment", + "Add stamp", + "Delete comment", + "Edit comment", + "Reply", + "Set status None", + "Set status Accepted", + "Set status Rejected", + "Set status Cancelled", + "Set status Completed", + "Replace text", + "Erase", + "Mark with checkmark", + "Remove checkmark", + "Lock comment", + "Unlock comment", + "Move comment", + "Reshape comment", + "Resize comment", + "Change comment properties", + "Fill in {name}", + "Set the image of {name}", + "Clear form", + "Add field", + "Change field properties", + "Delete field", + "Duplicate field", + "Set tab order", + "Set calculation order", + "Change initial view", + "Set alternate text", + "Mark figure as decorative", + "Recognize text", + "Run JavaScript", + "Run form script", + "Save as {level}", + "Remove {standard} identification", + "Edit script of {name}", + "Delete document JavaScript", + "Edit document JavaScript", + "Edit text", + "Load document font", + "Move object", + "Move objects", + "Move image", + "Rotate image", + "Flip image", + "Replace image", + "Delete image", + "Add header & footer", + "Update header & footer", + "Add watermark", + "Update watermark", + "Add background", + "Update background", + "Remove header & footer", + "Remove watermark", + "Remove background", + "Add text", + "Add image", + "Edit content", + "Delete content", + "Paste", + "Apply redactions", + "Remove redaction marks", + "Remove hidden information", + "Import {name}", + "Add link", + "Change link properties", + "Delete link", + "Remove all links", + "Create link", + "Create links", + "Sanitize document", + "Flatten comments", + "Flatten form fields", + "Flatten", + "Flatten Fill & Sign", + "Protect with password", + "Remove security", +]; + +/// The names of the batches built below L7, which an interface shows in the same places as +/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives +/// already worded in the interface's language. +pub const BATCH_LABELS: &[&str] = + &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"]; + +impl Edit { + /// Label for the Edit menu and history ("Undo Rotate pages"). + pub fn label(&self) -> Label { + match self { + Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), + Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), + Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), + Edit::ReversePages { .. } => Label::of("Reverse pages"), + Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), + Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()), + Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), + Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()), + Edit::SetPageBox { .. } => Label::of("Set page boxes"), + Edit::SetTransition { .. } => Label::of("Set page transitions"), + // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can + // be said in another language; any other key is the document's and goes in as it stands. + Edit::SetInfo { key, .. } => match key.as_str() { + "Title" => Label::of("Change Title"), + "Author" => Label::of("Change Author"), + "Subject" => Label::of("Change Subject"), + "Keywords" => Label::of("Change Keywords"), + _ => Label::with("Change {key}", key.as_str()), + }, + Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()), + Edit::AddBookmark { .. } => Label::of("Add bookmark"), + Edit::RenameBookmark { .. } => Label::of("Rename bookmark"), + Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), + Edit::MoveBookmark { .. } => Label::of("Move bookmark"), + Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), + // The bookmark Style and Colour menus' own names, already said in every language. + Edit::SetBookmarkColor { .. } => Label::of("Colour"), + Edit::SetBookmarkStyle { .. } => Label::of("Style"), + Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), + Edit::NumberPages { .. } => Label::of("Number pages"), + // What was measured is part of the wording too, for the same reason. + Edit::AddMeasurement(m) => Label::of(match m.kind { + pdfcraft_measure::Kind::Distance => "Measure distance", + pdfcraft_measure::Kind::Perimeter => "Measure perimeter", + pdfcraft_measure::Kind::Area => "Measure area", + }), + Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), + Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), + Edit::AddCustomStamp { .. } => Label::of("Add stamp"), + Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), + Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), + Edit::ReplyToAnnotation { .. } => Label::of("Reply"), + // The state's name is part of the wording: a language may need to agree it with the rest. + Edit::SetAnnotationStatus { state, .. } => Label::of(match state { + ReviewState::None => "Set status None", + ReviewState::Accepted => "Set status Accepted", + ReviewState::Rejected => "Set status Rejected", + ReviewState::Cancelled => "Set status Cancelled", + ReviewState::Completed => "Set status Completed", + }), + Edit::ReplaceText { .. } => Label::of("Replace text"), + Edit::EraseInk { .. } => Label::of("Erase"), + Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"), + Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"), + Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"), + Edit::LockAnnotation { .. } => Label::of("Unlock comment"), + Edit::MoveAnnotation { .. } => Label::of("Move comment"), + Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), + Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), + Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { + Label::of("Change comment properties") + } + Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), + Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), + Edit::ResetForm { .. } => Label::of("Clear form"), + Edit::AddField { .. } => Label::of("Add field"), + Edit::SetFieldProps { .. } => Label::of("Change field properties"), + Edit::DeleteField { .. } => Label::of("Delete field"), + Edit::DuplicateField { .. } => Label::of("Duplicate field"), + Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), + Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), + Edit::SetInitialView(_) => Label::of("Change initial view"), + Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), + Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), + Edit::AutoTagText => Label::of("Automatically tag text"), + Edit::SetAltText { .. } => Label::of("Set alternate text"), + Edit::AutoTagFormFields => Label::of("Tag form fields"), + Edit::SetTagProperty { .. } => Label::of("Change tag properties"), + Edit::MoveTag { .. } => Label::of("Move object"), + Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), + Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), + Edit::AddOcrText { .. } => Label::of("Recognize text"), + Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), + Edit::XfaEvent { .. } => Label::of("Run form script"), + // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. + Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), + Edit::RemoveStandardId { standard } => Label::with("Remove {standard} identification", standard.label()), + Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), + Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), + Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), + Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), + Edit::RegisterTextFont { .. } => Label::of("Load document font"), + Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), + Edit::EditPageImage { change, .. } => Label::of(match change { + ImageEdit::Move(_) => "Move image", + ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image", + ImageEdit::Crop(_) => "Crop image", + ImageEdit::Flip { .. } => "Flip image", + ImageEdit::Replace { .. } => "Replace image", + ImageEdit::Delete => "Delete image", + }), + Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), + Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), + Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"), + Edit::AddWatermark { .. } => Label::of("Update watermark"), + Edit::AddBackground { replace: false, .. } => Label::of("Add background"), + Edit::AddBackground { .. } => Label::of("Update background"), + Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), + Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), + Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), + Edit::AddText { .. } => Label::of("Add text"), + Edit::AddImage { .. } => Label::of("Add image"), + Edit::UpdateContent { .. } => Label::of("Edit content"), + Edit::DeleteContent { .. } => Label::of("Delete content"), + Edit::ReplaceImage { .. } => Label::of("Replace image"), + Edit::PasteContent { .. } => Label::of("Paste"), + Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), + Edit::ClearRedactions => Label::of("Remove redaction marks"), + Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), + Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()), + Edit::AddLink { .. } => Label::of("Add link"), + Edit::SetLink { .. } => Label::of("Change link properties"), + Edit::DeleteLink { .. } => Label::of("Delete link"), + Edit::RemoveLinks { .. } => Label::of("Remove all links"), + Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), + Edit::Sanitize => Label::of("Sanitize document"), + Edit::XfaToStatic => Label::of("Convert to a static form"), + Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), + Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), + Edit::Flatten { .. } => Label::of("Flatten"), + Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"), + Edit::Protect(_) => Label::of("Protect with password"), + Edit::RemoveProtection => Label::of("Remove security"), + // A batch carries its own name, already worded by whoever built it. + // A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`). + Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()), + } + } +} + +/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind, +/// so a language can give the noun the article and the gender its own grammar asks for. +fn add_annotation(s: &Shape) -> &'static str { + match s { + Shape::Note { .. } => "Add sticky note", + Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight", + Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline", + Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough", + Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline", + Shape::Rectangle { .. } => "Add rectangle", + Shape::Oval { .. } => "Add oval", + Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", + Shape::Line { .. } => "Add line", + Shape::Ink { .. } => "Add drawing", + Shape::TextBox { .. } => "Add text box", + Shape::Typewriter { .. } => "Add text", + Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark", + Shape::Mark { mark: FillMark::Cross, .. } => "Add cross", + Shape::Mark { mark: FillMark::Dot, .. } => "Add dot", + Shape::Mark { mark: FillMark::Line, .. } => "Add line", + Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature", + Shape::Redact { .. } => "Add redaction mark", + Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp", + Shape::Polygon { cloud: true, .. } => "Add cloud", + Shape::Polygon { .. } => "Add polygon", + Shape::PolyLine { .. } => "Add connected lines", + Shape::Callout { .. } => "Add callout", + Shape::Caret { .. } => "Add inserted text", + Shape::Attachment { .. } => "Add file attachment", + } +} + +/// Whether the opening password allows an edit (§7.6.4.2, Table 22). +pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { + match edit { + Edit::RotatePages { .. } + | Edit::DeletePages { .. } + | Edit::MovePages { .. } + | Edit::ReversePages { .. } + | Edit::InsertBlankPage { .. } + | Edit::DuplicatePages { .. } + | Edit::ReplacePages { .. } + | Edit::SetPageBox { .. } + | Edit::SetTransition { .. } + | Edit::InsertPagesFrom { .. } + // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). + | Edit::AddBookmark { .. } + | Edit::RenameBookmark { .. } + | Edit::DeleteBookmark { .. } + | Edit::MoveBookmark { .. } + | Edit::SetBookmarkPage { .. } + | Edit::SetBookmarkColor { .. } + | Edit::SetBookmarkStyle { .. } + | Edit::BookmarksFromStructure + | Edit::NumberPages { .. } => { + if p.assemble() { + Ok(()) + } else { + Err(EditError::NotPermitted("page changes")) + } + } + Edit::AddMeasurement(_) + | Edit::AddAnnotation(_) + | Edit::AddCustomStamp { .. } + | Edit::DeleteAnnotation { .. } + | Edit::SetAnnotationContents { .. } + | Edit::ReplyToAnnotation { .. } + | Edit::SetAnnotationStatus { .. } + | Edit::MarkAnnotation { .. } + | Edit::ReplaceText { .. } + | Edit::EraseInk { .. } + | Edit::LockAnnotation { .. } + | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } + | Edit::ResizeAnnotation { .. } + | Edit::StyleAnnotation { .. } + | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } + | Edit::SetAnnotationInfo { .. } + | Edit::SetMeasurementScale { .. } => { + if p.annotate() { + Ok(()) + } else { + Err(EditError::NotPermitted("comments")) + } + } + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { + if p.fill_forms() { + Ok(()) + } else { + Err(EditError::NotPermitted("filling in form fields")) + } + } + Edit::Protect(_) | Edit::RemoveProtection => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("changing security")) + } + } + // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted + // document it is a change of security (#3921). + Edit::ConvertPdfA { .. } => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")) + } + } + Edit::SetInfo { .. } + | Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::RemoveMarks { .. } + | Edit::AddField { .. } + | Edit::ApplyRedactions { .. } + | Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::MoveObjects { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + | Edit::ClearRedactions + | Edit::RemoveHidden { .. } + | Edit::ImportData { .. } + | Edit::AddLink { .. } + | Edit::SetLink { .. } + | Edit::DeleteLink { .. } + | Edit::RemoveLinks { .. } + | Edit::AddLinks { .. } + | Edit::Sanitize + | Edit::XfaToStatic + | Edit::SetFieldProps { .. } + | Edit::DeleteField { .. } + | Edit::DuplicateField { .. } + | Edit::SetTabOrder { .. } + | Edit::MoveInTabOrder { .. } + | Edit::SetCalculationOrder { .. } + | Edit::SetInitialView(_) + | Edit::AddAttachment { .. } + | Edit::DeleteAttachment { .. } + | Edit::AutoTagText + | Edit::SetAltText { .. } + | Edit::AutoTagFormFields + | Edit::SetTagProperty { .. } + | Edit::MoveTag { .. } + | Edit::MarkDecorative { .. } + | Edit::MarkTagArtifact { .. } + | Edit::AddOcrText { .. } + | Edit::SetDocumentScript { .. } + | Edit::SetFieldScript { .. } + | Edit::RemoveStandardId { .. } + | Edit::EditTextLine { .. } + | Edit::EditTextBlock { .. } + | Edit::RegisterTextFont { .. } + | Edit::EditPageImage { .. } + | Edit::Flatten { .. } + | Edit::FlattenFillSign => { + if p.modify() { + Ok(()) + } else { + Err(EditError::NotPermitted("changes to the document")) + } + } + Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)), + } +} + +/// Dates and unique ids stamped onto what an edit creates. +pub(crate) struct EditCtx { + /// Field scripts run (`None`: JavaScript is off) and what they produced. + pub(crate) js: Option, + /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what + /// its scripts produced. + pub(crate) xfa: Option>, + pub(crate) xfa_out: js::JsOutput, + /// The datasets stream this edit wrote (later writes in the edit replace it in place). + pub(crate) xfa_datasets: Option, + /// A form script ran too long and was abandoned: the document's scripts go off. + pub(crate) xfa_ran_away: bool, + /// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]). + pub(crate) xfa_time_scale: u32, + /// The edit took the XFA out of the form: what went with it, for the document's warnings. + pub(crate) xfa_removed: Vec, + /// Pages in the document, for `xfa.layout.pageCount()`. + pub(crate) pages: usize, + pub(crate) date: Option, + /// Today in local time, for date tokens. + pub(crate) today: (i64, u32, u32), + pub(crate) seed: u64, + pub(crate) count: u64, + /// Fields the edit renamed to keep imported names separate (old → new), for the repair + /// log and the undo label. + pub(crate) renamed_fields: Vec<(String, String)>, + /// The overlay streams the edit's applied redactions added, which alone the renderer's check + /// leaves out. + pub(crate) redaction_overlays: Vec, +} + +impl EditCtx { + pub(crate) fn new(now: Option, salt: u64) -> Self { + let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; + if let Some(t) = now { + seed ^= (t as u64).rotate_left(17); + } + #[cfg(not(target_arch = "wasm32"))] + if now.is_some() { + // Real clock: mix in sub-second time so ids from two sessions don't collide. + seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; + } + Self { + js: None, + xfa: None, + xfa_out: Default::default(), + xfa_datasets: None, + xfa_ran_away: false, + xfa_time_scale: 1, + xfa_removed: Vec::new(), + pages: 0, + date: now.map(pdfcraft_cos::pdf_date), + today: (1970, 1, 1), + seed, + count: 0, + renamed_fields: Vec::new(), + redaction_overlays: Vec::new(), + } + } + + /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the + /// operating system, so no extra dependency is needed. + fn entropy(&mut self) -> [u8; 32] { + use std::hash::{BuildHasher, Hasher}; + let mut out = [0u8; 32]; + for (i, chunk) in out.chunks_mut(8).enumerate() { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(self.seed ^ i as u64); + h.write_u64(self.count); + self.count += 1; + chunk.copy_from_slice(&h.finish().to_le_bytes()); + } + out + } + + /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. + fn meta(&mut self) -> pdfcraft_annot::Meta { + let mut next = || { + self.count += 1; + let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + }; + let (a, b) = (next(), next()); + let id = format!( + "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", + a >> 32, + (a >> 16) & 0xffff, + a & 0xfff, + 0x8000 | (b >> 48) & 0x3fff, + b & 0xffff_ffff_ffff + ); + pdfcraft_annot::Meta { date: self.date.clone(), id } + } +} + +/// Perform an edit on a working copy (the caller discards it on error). +pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { + match edit { + Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, + Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, + Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, + Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, + Edit::InsertBlankPage { at, width, height } => { + pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; + } + Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, + Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, + Edit::ReplacePages { pages, name, bytes, src_pages } => { + let src = open_source(name, bytes)?; + pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; + } + Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, + Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, + Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { + let src = open_source(name, bytes)?; + let pages = match pages { + Some(p) => p.clone(), + None => (0..pdfcraft_organize::page_count(&src)?).collect(), + }; + // The file's bookmarks come along under one named after it, as in Combine Files. + let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); + let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; + cx.renamed_fields = outcome.renamed_fields; + } + Edit::AddBookmark { parent, index, title, page, left, top } => { + pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?; + } + Edit::RenameBookmark { path, title } => pdfcraft_organize::rename_bookmark(doc, path, title)?, + Edit::DeleteBookmark { path } => pdfcraft_organize::delete_bookmark(doc, path)?, + Edit::MoveBookmark { from, to_parent, index } => { + pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?; + } + Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, + Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, + Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, + Edit::BookmarksFromStructure => { + let entries: Vec<_> = pdfcraft_a11y::headings(doc) + .into_iter() + .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) + .collect(); + pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; + } + Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, + Edit::AddMeasurement(m) => { + measure::add(doc, m, &cx.meta())?; + } + Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?, + Edit::AddAnnotation(a) => { + pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; + } + Edit::AddCustomStamp { page, rect, name, file, author } => { + let src = mark_source(doc, file)?; + let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); + let natural = rect[2] == rect[0] && rect[3] == rect[1]; + let rotation = pdfcraft_organize::page_rotation(doc, *page)?; + // The picture as displayed: a quarter turn swaps its sides in user space. + let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) }; + let rect = if natural { + let k = (200.0 / sw.max(sh)).min(1.0); + let (w, h) = (dw * k, dh * k); + [rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0] + } else { + fit_picture(*rect, dw, dh) + }; + let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; + let style = pdfcraft_annot::Style::default_for(&shape); + let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; + let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; + pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; + } + Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, + Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, + Edit::ReplyToAnnotation { page, index, text, author } => { + pdfcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?; + } + Edit::SetAnnotationStatus { page, index, state, author } => { + pdfcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; + } + Edit::EraseInk { page, index, path, radius } => { + pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; + } + Edit::AddAttachment { name, bytes, description, mime } => { + pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; + } + Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, + Edit::ReplaceText { page, quads, text, author, strike, caret } => { + // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by + // the strikeout's `/NM` (#2666). + let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); + pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; + } + Edit::MarkAnnotation { page, index, marked, author } => { + pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; + } + Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, + Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, + Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, + Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, + Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { + pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; + } + Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, + Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, + Edit::SetAnnotationInfo { page, index, author, subject, icon } => { + pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; + } + Edit::SetFieldValue { name, value } => { + let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() { + Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), + None => pdfcraft_forms::set_value(doc, name, value), + }; + match set(doc, value) { + Ok(()) => {} + // A date the field's format doesn't read may be typed by another of an XFA date + // field's patterns: it is kept in the first. Only a format refusal is tried + // again: it comes before any of the field's scripts run, so they run once. + Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { + Some(typed) => set(doc, &typed)?, + None => return Err(refused.into()), + }, + Err(refused) => return Err(refused.into()), + } + } + Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { + Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, + None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, + }, + Edit::XfaEvent { som, activity } => { + let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; + let mut run = + xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; + let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + ran.map_err(EditError::Invalid)?; + } + Edit::SetFieldImage { name, image } => { + let (img, _) = embed_image(doc, name, image)?; + let px = match &*doc.get(img) { + pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), + _ => (1, 1), + }; + // An XFA image field keeps the picture in the data too (as Designer saves it), and a + // laid-out form shows it from there: so only pictures the layout can show, and only + // for image fields (a laid-out one carries its SOM path; a static form's field name + // is its path). + let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); + let image_field = is_xfa + && pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon)); + if image_field { + let Some(content_type) = image_content_type(image) else { + return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those"))); + }; + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); + let path = match cx.xfa.as_deref() { + Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), + None => pdfcraft_xfa::som_to_path(&som), + }; + let r = + pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; + if !r.written && r.warnings.is_empty() { + return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); + } + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + xfa_after_data_change(doc, cx, false)?; + } else { + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + } + } + Edit::ResetForm { names } => { + pdfcraft_forms::reset(doc, names.as_deref())?; + } + Edit::AddField { page, rect, kind, name } => { + pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; + } + Edit::SetFieldProps { name, props } => { + pdfcraft_forms::set_props(doc, name, props)?; + } + Edit::DuplicateField { name, pages } => { + if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { + return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); + } + } + Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, + Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, + Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, + // As after a script edit: the values the new order gives show at once. + Edit::SetCalculationOrder { names } => { + pdfcraft_forms::set_calculation_order(doc, names)?; + match cx.js.as_mut() { + Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, + None => pdfcraft_forms::recalculate(doc)?, + }; + } + Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?, + Edit::AutoTagText => { + a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::SetAltText { figure, alt } => { + let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); + a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::AutoTagFormFields => { + a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; + } + Edit::SetTagProperty { object, property, value } => { + a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::MoveTag { object, anchor, placement } => { + a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::EditTextLine { page, line, text } => { + pdfcraft_edit::replace_line(doc, *page, *line, text)?; + } + Edit::SetFieldScript { name, event, script } => { + pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; + match cx.js.as_mut() { + Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, + None => pdfcraft_forms::recalculate(doc)?, + }; + } + Edit::ConvertPdfA { level } => { + pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?; + } + Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?, + Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?, + Edit::AddOcrText { page, words } => { + pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; + } + Edit::EditPageImage { page, index, change } => { + let img = pdfcraft_edit::page_images(doc, *page)? + .into_iter() + .nth(*index) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; + let c = match change { + ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), + ImageEdit::Rotate(q) => { + pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) + } + ImageEdit::RotateBy(degrees) => { + let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; + pdfcraft_edit::ImageChange::Transform(t) + } + ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( + img.visible_rect.unwrap_or(img.rect), + 0, + *horizontal, + !*horizontal, + )), + ImageEdit::Replace { name, bytes } => { + if img.is_form { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( + "grouped Form artwork cannot be replaced with a bitmap".into(), + ))); + } + pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) + } + ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), + ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, + }; + pdfcraft_edit::change_image(doc, *page, *index, &c)?; + } + Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, + Edit::EditTextBlock { page, block, text, style } => { + pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; + } + Edit::RegisterTextFont { page, bytes } => { + pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; + } + Edit::MarkDecorative { figure } => { + let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); + a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::MarkTagArtifact { object } => { + a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::AddHeaderFooter { pages, settings, replace } => { + let date = cx.today; + pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; + } + Edit::AddWatermark { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + pdfcraft_edit::add_watermark(doc, pages, &s, *replace)? + } + Edit::AddBackground { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + pdfcraft_edit::add_background(doc, pages, &s, *replace)? + } + Edit::RemoveMarks { kind } => { + let n = pdfcraft_model::pages(doc).len(); + if pdfcraft_edit::remove_marks(doc, &(0..n).collect::>(), *kind)? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into()))); + } + } + Edit::AddText { page, text } => { + pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; + } + Edit::AddImage { page, rect, name, bytes } => { + let (image, natural) = embed_image(doc, name, bytes)?; + let rect = match rect { + Some(r) => *r, + None => { + let p = pdfcraft_model::pages(doc).swap_remove(*page); + let (pw, ph) = p.display_size(doc); + let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0); + let (w, h) = (natural.0 * k, natural.1 * k); + [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] + } + }; + pdfcraft_edit::add_content(doc, *page, &AddedContent::Image(pdfcraft_edit::AddedImage::new(rect, image)))?; + } + Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?, + Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?, + Edit::ReplaceImage { page, index, name, bytes } => { + let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); + let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); + }; + let (image, _) = embed_image(doc, name, bytes)?; + pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; + } + Edit::PasteContent { page, content, file } => { + let content = match content { + AddedContent::Text(_) => content.clone(), + AddedContent::Image(img) => { + let Some((name, bytes)) = file else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into()))); + }; + // The same picture in this document (copied here, pasted here before, or an + // identical file): drawn again from the one image object rather than stored twice. + let image = if copy_image::is_picture(doc, img.image, bytes) { + img.image + } else { + match copy_image::added_picture(doc, bytes) { + Some(image) => image, + None => embed_image(doc, name, bytes)?.0, + } + }; + AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() }) + } + }; + pdfcraft_edit::add_content(doc, *page, &content)?; + } + Edit::ApplyRedactions { pages } => { + cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; + } + Edit::ClearRedactions => { + pdfcraft_redact::clear_marks(doc, None)?; + } + Edit::AddLink { page, rect, action, style } => { + pdfcraft_annot::links::add(doc, *page, *rect, action, style)?; + } + Edit::SetLink { page, index, rect, action, style } => pdfcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())?, + Edit::DeleteLink { page, index } => pdfcraft_annot::links::delete(doc, *page, *index)?, + Edit::RemoveLinks { pages } => { + if pdfcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there are no links to remove".into()))); + } + } + Edit::AddLinks { links, style } => { + if pdfcraft_annot::links::add_many(doc, links, style)? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); + } + } + Edit::ImportData { bytes, .. } => { + // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; + // XFDF, FDF, Acrobat's XML and tables go the usual way. + let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); + match text { + Some(Ok(text)) if is_xfa_data_file(&text) => { + let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); + let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) + .map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); + xfa_after_data_change(doc, cx, true)?; + } + Some(Err(why)) => return Err(EditError::Invalid(why)), + _ => { + pdfcraft_xfdf::import(doc, bytes)?; + } + } + } + Edit::RemoveHidden { which } => { + pdfcraft_redact::sanitize::remove_hidden(doc, which)?; + } + Edit::Sanitize => { + pdfcraft_redact::sanitize::sanitize(doc)?; + } + Edit::Flatten { comments, fields } => { + // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers + // would lay the form out again from its template over the baked pages. A dynamic + // form that was never laid out keeps its XFA: without it only placeholders remain. + let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; + match shape { + Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, + Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), + None => {} + } + } + Edit::XfaToStatic => { + let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { + return Err(EditError::Invalid("this document is not an XFA form".into())); + }; + if !shape.strippable() { + return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); + } + strip_xfa(doc, cx)?; + } + Edit::FlattenFillSign => { + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::>())?; + } + Edit::Protect(p) => { + p.validate()?; + let seed = cx.entropy(); + // Without a permissions password nothing is restricted, so the owner password is a + // random one nobody needs. + let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); + let params = pdfcraft_cos::NewEncryption { + algorithm: p.algorithm, + user_password: p.open_password.as_deref().unwrap_or(""), + owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner), + permissions: p.permission_bits(), + encrypt_metadata: p.encrypt_metadata, + seed, + }; + doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?; + } + Edit::RemoveProtection => { + if doc.output_handler().is_none() { + return Err(EditError::Protection("the document isn't protected".into())); + } + doc.remove_encryption(); + } + Edit::Batch { edits, .. } => { + for e in edits { + run_edit(doc, e, cx)?; + } + } + } + Ok(()) +} + +/// The singular or the plural wording, by count. Both are written out: a language forms its plural +/// its own way, and `one` and `many` may differ in more than an "s". +fn plural(one: &'static str, many: &'static str, n: usize) -> Label { + Label::of(if n == 1 { one } else { many }) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d34decd658..0926c3ed28 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod commands; pub mod compare; mod copy_image; pub mod dates; +mod edit; mod error; pub mod export; pub mod field_table; @@ -228,6 +229,8 @@ fn record_link_scan() { }); } +pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; +use edit::{EditCtx, check_permission, run_edit}; pub use error::{EditError, SourceProblem}; pub use history::Label; use history::{Editor, Keys, MAX_UNDO, Scope, TextBlocksCache, keys_after, scope_of, uses_scripts}; @@ -1228,1574 +1231,13 @@ impl Protection { } } -/// Edits that can be applied to a document. Page indices are 0-based. -#[derive(Clone, Debug, PartialEq)] -pub enum Edit { - RotatePages { - pages: Vec, - degrees: i64, - }, - DeletePages { - pages: Vec, - }, - MovePages { - pages: Vec, - to: usize, - }, - /// `pages` reversed among the positions they occupy. - ReversePages { - pages: Vec, - }, - InsertBlankPage { - at: usize, - width: f64, - height: f64, - }, - /// Copies of `pages` inserted at `at` (an index in the page order as it is now), or after - /// the last of them when `at` is `None`. - DuplicatePages { - pages: Vec, - at: Option, - }, - /// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the - /// pages' links, comments and bookmarks stay. - ReplacePages { - pages: Vec, - name: String, - bytes: Arc>, - src_pages: Vec, - }, - /// Set Page Boxes / Crop: set (or reset) one box on `pages`. - SetPageBox { - pages: Vec, - which: PageBox, - spec: BoxSpec, - }, - /// Set or remove the page transition and display time of pages (ISO 32000-2 §12.4.4), - /// Organize ▸ Page Transitions. - SetTransition { - pages: Vec, - spec: TransitionSpec, - }, - SetInfo { - key: String, - value: String, - }, - /// Insert pages from another PDF (all pages when `pages` is `None`) at position `at`. - InsertPagesFrom { - name: String, - bytes: Arc>, - pages: Option>, - at: usize, - /// How a form field whose name the document already uses is kept separate. - field_conflicts: pdfcraft_organize::FieldConflicts, - }, - /// Add a bookmark to `page` as child `index` of the bookmark at `parent` (`[]` = top level). - /// It goes to the user-space point (`left`, `top`) of the page at the reader's zoom, and - /// either may be `None` to keep the reader's position that way (both `None` is the page top). - /// A coordinate that is not finite fails the edit. - AddBookmark { - parent: Vec, - index: usize, - title: String, - page: usize, - left: Option, - top: Option, - }, - RenameBookmark { - path: Vec, - title: String, - }, - /// Delete a bookmark and the bookmarks under it. - DeleteBookmark { - path: Vec, - }, - /// Move a bookmark to child `index` of `to_parent` (indices after removing it). - MoveBookmark { - from: Vec, - to_parent: Vec, - index: usize, - }, - /// Point a bookmark at another page. - SetBookmarkPage { - path: Vec, - page: usize, - }, - /// Colour a bookmark's title (`/C`); `None` or black is the default black. - SetBookmarkColor { - path: Vec, - color: Option<[f64; 3]>, - }, - /// Make a bookmark's title bold or italic or not (`/F`); `None` keeps that one. - SetBookmarkStyle { - path: Vec, - bold: Option, - italic: Option, - }, - /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first - /// "Untitled" bookmark (New Bookmarks from Structure). - BookmarksFromStructure, - /// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels. - NumberPages { - from: usize, - to: usize, - style: pdfcraft_organize::LabelStyle, - prefix: String, - first: u32, - }, - /// Add a calibrated distance, perimeter or area annotation. - AddMeasurement(measure::NewMeasurement), - /// Store a drawing scale for a rectangular viewport (PDF user space). - SetMeasurementScale { - page: usize, - bbox: [f64; 4], - name: String, - scale: measure::Scale, - }, - /// Add a comment (sticky note, highlight, shape, drawing, text box…). - AddAnnotation(NewAnnotation), - /// A custom stamp from a picture file (a PDF page or an image) on `page`. A zero-size - /// `rect` is a point: the stamp is centred there at its natural size (at most 200 pt). - /// Any other `rect` is a box: the picture is scaled to fit inside it, keeping its shape, - /// and centred in it (#4077). - AddCustomStamp { - page: usize, - rect: [f64; 4], - name: String, - file: MarkFile, - author: String, - }, - /// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies. - DeleteAnnotation { - page: usize, - index: usize, - }, - SetAnnotationContents { - page: usize, - index: usize, - text: String, - }, - ReplyToAnnotation { - page: usize, - index: usize, - text: String, - author: String, - }, - /// Acrobat's "Set status" (a state reply by `author`). - SetAnnotationStatus { - page: usize, - index: usize, - state: ReviewState, - author: String, - }, - /// The Eraser: rub out the parts of drawing `(page, index)` near `path` (user space). - EraseInk { - page: usize, - index: usize, - path: Vec<[f64; 2]>, - radius: f64, - }, - /// Replace Text: strike out `quads` and add a grouped caret holding `text`. - ReplaceText { - page: usize, - quads: Vec<[f64; 8]>, - text: String, - author: String, - strike: Style, - caret: Style, - }, - /// Acrobat's "Mark with checkmark" (a hidden `/StateModel /Marked` reply by `author`). - MarkAnnotation { - page: usize, - index: usize, - marked: bool, - author: String, - }, - /// Properties ▸ Locked. - LockAnnotation { - page: usize, - index: usize, - locked: bool, - }, - MoveAnnotation { - page: usize, - index: usize, - dx: f64, - dy: f64, - }, - /// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's - /// two ends, the others' corners). - ReshapeAnnotation { - page: usize, - index: usize, - points: Vec<[f64; 2]>, - }, - /// Resize a rectangle, oval or text box. - ResizeAnnotation { - page: usize, - index: usize, - rect: [f64; 4], - }, - StyleAnnotation { - page: usize, - index: usize, - color: Option, - opacity: Option, - width: Option, - /// Line or polyline: two endings. Callout: one. `None` leaves `/LE` unchanged. - endings: Option>, - }, - /// Fill a rectangle, oval or polygon comment, or remove its fill (`None`). - FillAnnotation { - page: usize, - index: usize, - fill: Option, - }, - /// Change the font size (pt) of a text box, typewriter text or callout. - FontSizeAnnotation { - page: usize, - index: usize, - size: f64, - }, - /// Comment properties ▸ General / note icon. - SetAnnotationInfo { - page: usize, - index: usize, - author: Option, - subject: Option, - icon: Option, - }, - /// Fill in a form field. - SetFieldValue { - name: String, - value: FieldValue, - }, - /// Clear form: the named fields (all when `None`) back to their defaults. - ResetForm { - names: Option>, - }, - /// Prepare a form: add a field on a page (`rect` in PDF user space); `name` defaults to - /// Acrobat's next free name ("Text1", "Check Box2", …). - AddField { - page: usize, - rect: [f64; 4], - kind: NewField, - name: Option, - }, - /// Field Properties ▸ General / Options. - SetFieldProps { - name: String, - props: Box, - }, - /// An image field (or any button): show this image file (PNG, JPEG, TIFF, GIF, BMP). - SetFieldImage { - name: String, - image: Arc>, - }, - /// Duplicate a field onto other pages (same field, shared value). - DuplicateField { - name: String, - pages: Vec, - }, - DeleteField { - name: String, - }, - /// Page properties ▸ Tab order, for pages (0-based). - SetTabOrder { - pages: Vec, - order: TabOrder, - }, - /// Document Properties ▸ Initial View (and the language and binding). - SetInitialView(Box), - /// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under - /// `name`, made unique; `mime` is the embedded file's `/Subtype` when known. - AddAttachment { - name: String, - bytes: Vec, - description: Option, - mime: Option, - }, - /// Remove the document-level attachment whose name-tree key is `key`. - DeleteAttachment { - key: String, - }, - /// Build a tagged structure tree for an untagged, text-only document. - AutoTagText, - /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is - /// the figure element's object number (from `Document::figures`). - SetAltText { - figure: u32, - alt: Option, - }, - /// Add `/Form` tags and object references for all widgets in an untagged AcroForm. - AutoTagFormFields, - /// Change one property on a reachable indirect structure tag. - SetTagProperty { - object: pdfcraft_cos::ObjRef, - property: a11y::TagProperty, - value: Option, - }, - /// Move a structure tag before or after a direct sibling in reading order. - MoveTag { - object: pdfcraft_cos::ObjRef, - anchor: pdfcraft_cos::ObjRef, - placement: a11y::TagPlacement, - }, - /// Add alternate text ▸ Decorative figure: the figure's content becomes an artifact. - MarkDecorative { - figure: u32, - }, - /// Tags panel ▸ Mark as artifact: retire a leaf tag and mark its content as an artifact. - MarkTagArtifact { - object: pdfcraft_cos::ObjRef, - }, - /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level - /// script `name`. - SetDocumentScript { - name: String, - script: Option, - }, - /// Field Properties ▸ a custom script: set or remove field `name`'s JavaScript for `event` - /// (keystroke, format, validate, calculate or mouse_up). - SetFieldScript { - name: String, - event: String, - script: Option, - }, - /// Standards ▸ Save as PDF/A: fix what can be fixed for `level` (metadata, output intent, - /// forbidden actions, annotation flags, …). - ConvertPdfA { - level: pdfcraft_preflight::Level, - }, - /// Remove what identifies the document as meeting `standard` (PDF/A or PDF/X). - RemoveStandardId { - standard: Standard, - }, - /// What a script (button or console) changed in form fields: values, read-only, required - /// and visibility. - ApplyScriptChanges { - changes: Vec, - }, - /// An XFA form's scripted event (a button's `click`, say) on the object at `som`: what the - /// script changes (values, rows, visibility) is applied and the form laid out again. - XfaEvent { - som: String, - activity: String, - }, - /// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the - /// image (from [`ocr::OcrJob::run`]). - AddOcrText { - page: usize, - words: Vec, - }, - /// Edit a PDF ▸ Edit text: replace the text of line `line` (from `Document::text_lines`) on - /// `page`, in its own font when it can show it, else in Helvetica. - EditTextLine { - page: usize, - line: usize, - text: String, - }, - /// Edit a PDF ▸ an existing image on `page` (index from `Document::page_images`). - EditPageImage { - page: usize, - index: usize, - change: ImageEdit, - }, - /// Move mixed existing and added objects together. References resolve before any change; - /// offset is a user-space vector. No retyping, reflow or font substitution is involved. - MoveObjects { - page: usize, - objects: Vec, - offset: [f64; 2], - }, - /// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`), - /// rewrapped to the box's width. - EditTextBlock { - page: usize, - block: usize, - text: String, - /// Formatting changes (font, size, colour, alignment); default keeps the paragraph's. - style: pdfcraft_edit::BlockStyle, - }, - /// An explicit open-licensed full font program, completing subsets or selected by a user. - RegisterTextFont { - page: usize, - bytes: Arc>, - }, - /// Order tabs manually: move a field one place earlier or later on its page. - MoveInTabOrder { - name: String, - earlier: bool, - }, - /// Set the calculation order: these calculated fields first, in this order, then the others - /// in their current order (see [`Document::calculation_order`]). The form recalculates. - SetCalculationOrder { - names: Vec, - }, - /// Add a header and footer (with `replace`, existing ones on those pages go first). - AddHeaderFooter { - pages: Vec, - settings: HeaderFooter, - replace: bool, - }, - /// `file`: a picture from a file (an image, or a page of a PDF) instead of text. - AddWatermark { - pages: Vec, - settings: Watermark, - replace: bool, - file: Option, - }, - /// `file`: a picture from a file instead of a colour. - AddBackground { - pages: Vec, - settings: Background, - replace: bool, - file: Option, - }, - /// Remove every mark of a kind from every page. - RemoveMarks { - kind: MarkKind, - }, - /// Edit a PDF ▸ Add content ▸ Text (`text.rect` in display space). - AddText { - page: usize, - text: AddedText, - }, - /// Edit a PDF ▸ Add content ▸ Image: an image file placed at `rect` (display space), or at - /// its natural size centred on the page (shrunk to fit). - AddImage { - page: usize, - rect: Option<[f64; 4]>, - name: String, - bytes: Arc>, - }, - /// Move, resize, retype or reformat an added item (`index` among the page's added items). - UpdateContent { - page: usize, - index: usize, - content: AddedContent, - }, - DeleteContent { - page: usize, - index: usize, - }, - /// Replace an added image with another file (keeping its box and transforms). - ReplaceImage { - page: usize, - index: usize, - name: String, - bytes: Arc>, - }, - /// Edit ▸ Paste: an added item copied in this document or another, as it was: text with its - /// style, an image in its box (display space) with its turns, flips and crop. An image's - /// picture is `file` (from [`Document::added_image_file`]): the copied image object is reused - /// while it is still that picture in this document, else one an added image here already draws - /// it from, else the file is embedded. - PasteContent { - page: usize, - content: AddedContent, - file: Option<(String, Arc>)>, - }, - /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. - ApplyRedactions { - pages: Option>, - }, - /// Remove redaction marks without applying them. - ClearRedactions, - /// Edit a PDF ▸ Link: a new link over `rect` (user space). - AddLink { - page: usize, - rect: [f64; 4], - action: LinkAction, - style: LinkStyle, - }, - /// Link Properties (`index` in the page's `/Annots`). - SetLink { - page: usize, - index: usize, - rect: Option<[f64; 4]>, - action: Option, - style: Option, - }, - DeleteLink { - page: usize, - index: usize, - }, - /// Remove all links (on `pages`, or everywhere). - RemoveLinks { - pages: Option>, - }, - /// Create links from URLs in the text: (page, line rects in user space, URI). - AddLinks { - links: Vec<(usize, Vec<[f64; 4]>, String)>, - style: LinkStyle, - }, - /// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text). - ImportData { - name: String, - bytes: Arc>, - }, - /// Remove Hidden Information: the chosen categories. - RemoveHidden { - which: Vec, - }, - /// Sanitize Document: every category, then a full rewrite on save. - Sanitize, - /// An XFA form made an ordinary (static) PDF form: the XFA packets, `/NeedsRendering` and - /// PdfCraft's private keys go, the pages as laid out and the fields with their values - /// stay, so every viewer shows the same form and none lays it out again. Its scripts no - /// longer run. - XfaToStatic, - /// Flatten comments and/or form fields on every page into page content. - Flatten { - comments: bool, - fields: bool, - }, - /// Bake Fill & Sign text, marks and signatures into the page. Other comments and fields stay. - /// A no-op (no undo step) when the document has none. - FlattenFillSign, - /// Protect with passwords and permissions (written by the next save, which is a full rewrite). - Protect(Protection), - /// Remove password security (needs the owner password). - RemoveProtection, - /// Several edits applied as one undoable step (all or nothing). - Batch { - label: String, - edits: Vec, - }, -} - -/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not -/// here: its wording is only the hole, because a batch carries its name as text (see -/// [`BATCH_LABELS`]). -pub const EDIT_LABELS: &[&str] = &[ - "Rotate page", - "Change tag properties", - "Rotate pages", - "Delete page", - "Delete pages", - "Move page", - "Move pages", - "Reverse pages", - "Insert blank page", - "Duplicate page", - "Duplicate pages", - "Replace page", - "Replace pages", - "Crop page", - "Crop pages", - "Set page boxes", - "Set page transitions", - "Change Title", - "Change Author", - "Change Subject", - "Change Keywords", - "Change {key}", - "Insert pages from {name}", - "Insert pages from {name} — 1 field renamed", - "Insert pages from {name} — fields renamed", - "Attach {name}", - "Delete attachment {key}", - "Add bookmark", - "Rename bookmark", - "Delete bookmark", - "Move bookmark", - "Set bookmark destination", - "Colour", - "Style", - "New bookmarks from structure", - "Number pages", - "Measure distance", - "Measure perimeter", - "Measure area", - "Set measurement scale", - "Add sticky note", - "Add highlight", - "Add underline", - "Add strikethrough", - "Add squiggly underline", - "Add rectangle", - "Add oval", - "Add arrow", - "Add line", - "Add drawing", - "Add text box", - "Add checkmark", - "Add cross", - "Add dot", - "Add signature", - "Add redaction mark", - "Add cloud", - "Add polygon", - "Add connected lines", - "Add callout", - "Add inserted text", - "Add file attachment", - "Add stamp", - "Delete comment", - "Edit comment", - "Reply", - "Set status None", - "Set status Accepted", - "Set status Rejected", - "Set status Cancelled", - "Set status Completed", - "Replace text", - "Erase", - "Mark with checkmark", - "Remove checkmark", - "Lock comment", - "Unlock comment", - "Move comment", - "Reshape comment", - "Resize comment", - "Change comment properties", - "Fill in {name}", - "Set the image of {name}", - "Clear form", - "Add field", - "Change field properties", - "Delete field", - "Duplicate field", - "Set tab order", - "Set calculation order", - "Change initial view", - "Set alternate text", - "Mark figure as decorative", - "Recognize text", - "Run JavaScript", - "Run form script", - "Save as {level}", - "Remove {standard} identification", - "Edit script of {name}", - "Delete document JavaScript", - "Edit document JavaScript", - "Edit text", - "Load document font", - "Move object", - "Move objects", - "Move image", - "Rotate image", - "Flip image", - "Replace image", - "Delete image", - "Add header & footer", - "Update header & footer", - "Add watermark", - "Update watermark", - "Add background", - "Update background", - "Remove header & footer", - "Remove watermark", - "Remove background", - "Add text", - "Add image", - "Edit content", - "Delete content", - "Paste", - "Apply redactions", - "Remove redaction marks", - "Remove hidden information", - "Import {name}", - "Add link", - "Change link properties", - "Delete link", - "Remove all links", - "Create link", - "Create links", - "Sanitize document", - "Flatten comments", - "Flatten form fields", - "Flatten", - "Flatten Fill & Sign", - "Protect with password", - "Remove security", -]; - -/// The names of the batches built below L7, which an interface shows in the same places as -/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives -/// already worded in the interface's language. -pub const BATCH_LABELS: &[&str] = - &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"]; - -impl Edit { - /// Label for the Edit menu and history ("Undo Rotate pages"). - pub fn label(&self) -> Label { - match self { - Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), - Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), - Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), - Edit::ReversePages { .. } => Label::of("Reverse pages"), - Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), - Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()), - Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), - Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()), - Edit::SetPageBox { .. } => Label::of("Set page boxes"), - Edit::SetTransition { .. } => Label::of("Set page transitions"), - // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can - // be said in another language; any other key is the document's and goes in as it stands. - Edit::SetInfo { key, .. } => match key.as_str() { - "Title" => Label::of("Change Title"), - "Author" => Label::of("Change Author"), - "Subject" => Label::of("Change Subject"), - "Keywords" => Label::of("Change Keywords"), - _ => Label::with("Change {key}", key.as_str()), - }, - Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()), - Edit::AddBookmark { .. } => Label::of("Add bookmark"), - Edit::RenameBookmark { .. } => Label::of("Rename bookmark"), - Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), - Edit::MoveBookmark { .. } => Label::of("Move bookmark"), - Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), - // The bookmark Style and Colour menus' own names, already said in every language. - Edit::SetBookmarkColor { .. } => Label::of("Colour"), - Edit::SetBookmarkStyle { .. } => Label::of("Style"), - Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), - Edit::NumberPages { .. } => Label::of("Number pages"), - // What was measured is part of the wording too, for the same reason. - Edit::AddMeasurement(m) => Label::of(match m.kind { - pdfcraft_measure::Kind::Distance => "Measure distance", - pdfcraft_measure::Kind::Perimeter => "Measure perimeter", - pdfcraft_measure::Kind::Area => "Measure area", - }), - Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), - Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), - Edit::AddCustomStamp { .. } => Label::of("Add stamp"), - Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), - Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), - Edit::ReplyToAnnotation { .. } => Label::of("Reply"), - // The state's name is part of the wording: a language may need to agree it with the rest. - Edit::SetAnnotationStatus { state, .. } => Label::of(match state { - ReviewState::None => "Set status None", - ReviewState::Accepted => "Set status Accepted", - ReviewState::Rejected => "Set status Rejected", - ReviewState::Cancelled => "Set status Cancelled", - ReviewState::Completed => "Set status Completed", - }), - Edit::ReplaceText { .. } => Label::of("Replace text"), - Edit::EraseInk { .. } => Label::of("Erase"), - Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"), - Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"), - Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"), - Edit::LockAnnotation { .. } => Label::of("Unlock comment"), - Edit::MoveAnnotation { .. } => Label::of("Move comment"), - Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), - Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), - Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { - Label::of("Change comment properties") - } - Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), - Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), - Edit::ResetForm { .. } => Label::of("Clear form"), - Edit::AddField { .. } => Label::of("Add field"), - Edit::SetFieldProps { .. } => Label::of("Change field properties"), - Edit::DeleteField { .. } => Label::of("Delete field"), - Edit::DuplicateField { .. } => Label::of("Duplicate field"), - Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), - Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), - Edit::SetInitialView(_) => Label::of("Change initial view"), - Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), - Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), - Edit::AutoTagText => Label::of("Automatically tag text"), - Edit::SetAltText { .. } => Label::of("Set alternate text"), - Edit::AutoTagFormFields => Label::of("Tag form fields"), - Edit::SetTagProperty { .. } => Label::of("Change tag properties"), - Edit::MoveTag { .. } => Label::of("Move object"), - Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), - Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), - Edit::AddOcrText { .. } => Label::of("Recognize text"), - Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), - Edit::XfaEvent { .. } => Label::of("Run form script"), - // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. - Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), - Edit::RemoveStandardId { standard } => Label::with("Remove {standard} identification", standard.label()), - Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), - Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), - Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), - Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), - Edit::RegisterTextFont { .. } => Label::of("Load document font"), - Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), - Edit::EditPageImage { change, .. } => Label::of(match change { - ImageEdit::Move(_) => "Move image", - ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image", - ImageEdit::Crop(_) => "Crop image", - ImageEdit::Flip { .. } => "Flip image", - ImageEdit::Replace { .. } => "Replace image", - ImageEdit::Delete => "Delete image", - }), - Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), - Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), - Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"), - Edit::AddWatermark { .. } => Label::of("Update watermark"), - Edit::AddBackground { replace: false, .. } => Label::of("Add background"), - Edit::AddBackground { .. } => Label::of("Update background"), - Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), - Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), - Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), - Edit::AddText { .. } => Label::of("Add text"), - Edit::AddImage { .. } => Label::of("Add image"), - Edit::UpdateContent { .. } => Label::of("Edit content"), - Edit::DeleteContent { .. } => Label::of("Delete content"), - Edit::ReplaceImage { .. } => Label::of("Replace image"), - Edit::PasteContent { .. } => Label::of("Paste"), - Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), - Edit::ClearRedactions => Label::of("Remove redaction marks"), - Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), - Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()), - Edit::AddLink { .. } => Label::of("Add link"), - Edit::SetLink { .. } => Label::of("Change link properties"), - Edit::DeleteLink { .. } => Label::of("Delete link"), - Edit::RemoveLinks { .. } => Label::of("Remove all links"), - Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), - Edit::Sanitize => Label::of("Sanitize document"), - Edit::XfaToStatic => Label::of("Convert to a static form"), - Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), - Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), - Edit::Flatten { .. } => Label::of("Flatten"), - Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"), - Edit::Protect(_) => Label::of("Protect with password"), - Edit::RemoveProtection => Label::of("Remove security"), - // A batch carries its own name, already worded by whoever built it. - // A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`). - Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()), - } - } -} - -/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind, -/// so a language can give the noun the article and the gender its own grammar asks for. -fn add_annotation(s: &Shape) -> &'static str { - match s { - Shape::Note { .. } => "Add sticky note", - Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight", - Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline", - Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough", - Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline", - Shape::Rectangle { .. } => "Add rectangle", - Shape::Oval { .. } => "Add oval", - Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", - Shape::Line { .. } => "Add line", - Shape::Ink { .. } => "Add drawing", - Shape::TextBox { .. } => "Add text box", - Shape::Typewriter { .. } => "Add text", - Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark", - Shape::Mark { mark: FillMark::Cross, .. } => "Add cross", - Shape::Mark { mark: FillMark::Dot, .. } => "Add dot", - Shape::Mark { mark: FillMark::Line, .. } => "Add line", - Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature", - Shape::Redact { .. } => "Add redaction mark", - Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp", - Shape::Polygon { cloud: true, .. } => "Add cloud", - Shape::Polygon { .. } => "Add polygon", - Shape::PolyLine { .. } => "Add connected lines", - Shape::Callout { .. } => "Add callout", - Shape::Caret { .. } => "Add inserted text", - Shape::Attachment { .. } => "Add file attachment", - } -} - /// Opened as owner, or nothing is restricted (no permissions password was set): security may /// be changed, as in Acrobat. -fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { +pub(crate) fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12 p.owner || p.bits & ALL == ALL } -/// Whether the opening password allows an edit (§7.6.4.2, Table 22). -fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { - match edit { - Edit::RotatePages { .. } - | Edit::DeletePages { .. } - | Edit::MovePages { .. } - | Edit::ReversePages { .. } - | Edit::InsertBlankPage { .. } - | Edit::DuplicatePages { .. } - | Edit::ReplacePages { .. } - | Edit::SetPageBox { .. } - | Edit::SetTransition { .. } - | Edit::InsertPagesFrom { .. } - // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). - | Edit::AddBookmark { .. } - | Edit::RenameBookmark { .. } - | Edit::DeleteBookmark { .. } - | Edit::MoveBookmark { .. } - | Edit::SetBookmarkPage { .. } - | Edit::SetBookmarkColor { .. } - | Edit::SetBookmarkStyle { .. } - | Edit::BookmarksFromStructure - | Edit::NumberPages { .. } => { - if p.assemble() { - Ok(()) - } else { - Err(EditError::NotPermitted("page changes")) - } - } - Edit::AddMeasurement(_) - | Edit::AddAnnotation(_) - | Edit::AddCustomStamp { .. } - | Edit::DeleteAnnotation { .. } - | Edit::SetAnnotationContents { .. } - | Edit::ReplyToAnnotation { .. } - | Edit::SetAnnotationStatus { .. } - | Edit::MarkAnnotation { .. } - | Edit::ReplaceText { .. } - | Edit::EraseInk { .. } - | Edit::LockAnnotation { .. } - | Edit::MoveAnnotation { .. } - | Edit::ReshapeAnnotation { .. } - | Edit::ResizeAnnotation { .. } - | Edit::StyleAnnotation { .. } - | Edit::FillAnnotation { .. } - | Edit::FontSizeAnnotation { .. } - | Edit::SetAnnotationInfo { .. } - | Edit::SetMeasurementScale { .. } => { - if p.annotate() { - Ok(()) - } else { - Err(EditError::NotPermitted("comments")) - } - } - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { - if p.fill_forms() { - Ok(()) - } else { - Err(EditError::NotPermitted("filling in form fields")) - } - } - Edit::Protect(_) | Edit::RemoveProtection => { - if unrestricted(p) { - Ok(()) - } else { - Err(EditError::NotPermitted("changing security")) - } - } - // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted - // document it is a change of security (#3921). - Edit::ConvertPdfA { .. } => { - if unrestricted(p) { - Ok(()) - } else { - Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")) - } - } - Edit::SetInfo { .. } - | Edit::AddHeaderFooter { .. } - | Edit::AddWatermark { .. } - | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } - | Edit::AddField { .. } - | Edit::ApplyRedactions { .. } - | Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::MoveObjects { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - | Edit::ClearRedactions - | Edit::RemoveHidden { .. } - | Edit::ImportData { .. } - | Edit::AddLink { .. } - | Edit::SetLink { .. } - | Edit::DeleteLink { .. } - | Edit::RemoveLinks { .. } - | Edit::AddLinks { .. } - | Edit::Sanitize - | Edit::XfaToStatic - | Edit::SetFieldProps { .. } - | Edit::DeleteField { .. } - | Edit::DuplicateField { .. } - | Edit::SetTabOrder { .. } - | Edit::MoveInTabOrder { .. } - | Edit::SetCalculationOrder { .. } - | Edit::SetInitialView(_) - | Edit::AddAttachment { .. } - | Edit::DeleteAttachment { .. } - | Edit::AutoTagText - | Edit::SetAltText { .. } - | Edit::AutoTagFormFields - | Edit::SetTagProperty { .. } - | Edit::MoveTag { .. } - | Edit::MarkDecorative { .. } - | Edit::MarkTagArtifact { .. } - | Edit::AddOcrText { .. } - | Edit::SetDocumentScript { .. } - | Edit::SetFieldScript { .. } - | Edit::RemoveStandardId { .. } - | Edit::EditTextLine { .. } - | Edit::EditTextBlock { .. } - | Edit::RegisterTextFont { .. } - | Edit::EditPageImage { .. } - | Edit::Flatten { .. } - | Edit::FlattenFillSign => { - if p.modify() { - Ok(()) - } else { - Err(EditError::NotPermitted("changes to the document")) - } - } - Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)), - } -} - -/// Dates and unique ids stamped onto what an edit creates. -struct EditCtx { - /// Field scripts run (`None`: JavaScript is off) and what they produced. - js: Option, - /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what - /// its scripts produced. - xfa: Option>, - xfa_out: js::JsOutput, - /// The datasets stream this edit wrote (later writes in the edit replace it in place). - xfa_datasets: Option, - /// A form script ran too long and was abandoned: the document's scripts go off. - xfa_ran_away: bool, - /// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]). - xfa_time_scale: u32, - /// The edit took the XFA out of the form: what went with it, for the document's warnings. - xfa_removed: Vec, - /// Pages in the document, for `xfa.layout.pageCount()`. - pages: usize, - date: Option, - /// Today in local time, for date tokens. - today: (i64, u32, u32), - seed: u64, - count: u64, - /// Fields the edit renamed to keep imported names separate (old → new), for the repair - /// log and the undo label. - renamed_fields: Vec<(String, String)>, - /// The overlay streams the edit's applied redactions added, which alone the renderer's check - /// leaves out. - redaction_overlays: Vec, -} - -impl EditCtx { - fn new(now: Option, salt: u64) -> Self { - let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; - if let Some(t) = now { - seed ^= (t as u64).rotate_left(17); - } - #[cfg(not(target_arch = "wasm32"))] - if now.is_some() { - // Real clock: mix in sub-second time so ids from two sessions don't collide. - seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; - } - Self { - js: None, - xfa: None, - xfa_out: Default::default(), - xfa_datasets: None, - xfa_ran_away: false, - xfa_time_scale: 1, - xfa_removed: Vec::new(), - pages: 0, - date: now.map(pdfcraft_cos::pdf_date), - today: (1970, 1, 1), - seed, - count: 0, - renamed_fields: Vec::new(), - redaction_overlays: Vec::new(), - } - } - - /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the - /// operating system, so no extra dependency is needed. - fn entropy(&mut self) -> [u8; 32] { - use std::hash::{BuildHasher, Hasher}; - let mut out = [0u8; 32]; - for (i, chunk) in out.chunks_mut(8).enumerate() { - let mut h = std::collections::hash_map::RandomState::new().build_hasher(); - h.write_u64(self.seed ^ i as u64); - h.write_u64(self.count); - self.count += 1; - chunk.copy_from_slice(&h.finish().to_le_bytes()); - } - out - } - - /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. - fn meta(&mut self) -> pdfcraft_annot::Meta { - let mut next = || { - self.count += 1; - let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15)); - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - }; - let (a, b) = (next(), next()); - let id = format!( - "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", - a >> 32, - (a >> 16) & 0xffff, - a & 0xfff, - 0x8000 | (b >> 48) & 0x3fff, - b & 0xffff_ffff_ffff - ); - pdfcraft_annot::Meta { date: self.date.clone(), id } - } -} - -/// Perform an edit on a working copy (the caller discards it on error). -fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { - match edit { - Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, - Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, - Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, - Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, - Edit::InsertBlankPage { at, width, height } => { - pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; - } - Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, - Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, - Edit::ReplacePages { pages, name, bytes, src_pages } => { - let src = open_source(name, bytes)?; - pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; - } - Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, - Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, - Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { - let src = open_source(name, bytes)?; - let pages = match pages { - Some(p) => p.clone(), - None => (0..pdfcraft_organize::page_count(&src)?).collect(), - }; - // The file's bookmarks come along under one named after it, as in Combine Files. - let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); - let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; - cx.renamed_fields = outcome.renamed_fields; - } - Edit::AddBookmark { parent, index, title, page, left, top } => { - pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?; - } - Edit::RenameBookmark { path, title } => pdfcraft_organize::rename_bookmark(doc, path, title)?, - Edit::DeleteBookmark { path } => pdfcraft_organize::delete_bookmark(doc, path)?, - Edit::MoveBookmark { from, to_parent, index } => { - pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?; - } - Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, - Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, - Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, - Edit::BookmarksFromStructure => { - let entries: Vec<_> = pdfcraft_a11y::headings(doc) - .into_iter() - .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) - .collect(); - pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; - } - Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, - Edit::AddMeasurement(m) => { - measure::add(doc, m, &cx.meta())?; - } - Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?, - Edit::AddAnnotation(a) => { - pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; - } - Edit::AddCustomStamp { page, rect, name, file, author } => { - let src = mark_source(doc, file)?; - let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); - let natural = rect[2] == rect[0] && rect[3] == rect[1]; - let rotation = pdfcraft_organize::page_rotation(doc, *page)?; - // The picture as displayed: a quarter turn swaps its sides in user space. - let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) }; - let rect = if natural { - let k = (200.0 / sw.max(sh)).min(1.0); - let (w, h) = (dw * k, dh * k); - [rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0] - } else { - fit_picture(*rect, dw, dh) - }; - let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; - let style = pdfcraft_annot::Style::default_for(&shape); - let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; - let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; - pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; - } - Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, - Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, - Edit::ReplyToAnnotation { page, index, text, author } => { - pdfcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?; - } - Edit::SetAnnotationStatus { page, index, state, author } => { - pdfcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; - } - Edit::EraseInk { page, index, path, radius } => { - pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; - } - Edit::AddAttachment { name, bytes, description, mime } => { - pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; - } - Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, - Edit::ReplaceText { page, quads, text, author, strike, caret } => { - // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by - // the strikeout's `/NM` (#2666). - let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); - pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; - } - Edit::MarkAnnotation { page, index, marked, author } => { - pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; - } - Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, - Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, - Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, - Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, - Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { - pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; - } - Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, - Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, - Edit::SetAnnotationInfo { page, index, author, subject, icon } => { - pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; - } - Edit::SetFieldValue { name, value } => { - let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() { - Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), - None => pdfcraft_forms::set_value(doc, name, value), - }; - match set(doc, value) { - Ok(()) => {} - // A date the field's format doesn't read may be typed by another of an XFA date - // field's patterns: it is kept in the first. Only a format refusal is tried - // again: it comes before any of the field's scripts run, so they run once. - Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { - Some(typed) => set(doc, &typed)?, - None => return Err(refused.into()), - }, - Err(refused) => return Err(refused.into()), - } - } - Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { - Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, - None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, - }, - Edit::XfaEvent { som, activity } => { - let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; - let mut run = - xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; - let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - ran.map_err(EditError::Invalid)?; - } - Edit::SetFieldImage { name, image } => { - let (img, _) = embed_image(doc, name, image)?; - let px = match &*doc.get(img) { - pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), - _ => (1, 1), - }; - // An XFA image field keeps the picture in the data too (as Designer saves it), and a - // laid-out form shows it from there: so only pictures the layout can show, and only - // for image fields (a laid-out one carries its SOM path; a static form's field name - // is its path). - let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); - let image_field = is_xfa - && pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon)); - if image_field { - let Some(content_type) = image_content_type(image) else { - return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those"))); - }; - pdfcraft_forms::set_button_icon(doc, name, img, px)?; - let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); - let path = match cx.xfa.as_deref() { - Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), - None => pdfcraft_xfa::som_to_path(&som), - }; - let r = - pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; - if !r.written && r.warnings.is_empty() { - return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); - } - cx.xfa_datasets = r.stream.or(cx.xfa_datasets); - cx.xfa_out.errors.extend(r.warnings); - xfa_after_data_change(doc, cx, false)?; - } else { - pdfcraft_forms::set_button_icon(doc, name, img, px)?; - } - } - Edit::ResetForm { names } => { - pdfcraft_forms::reset(doc, names.as_deref())?; - } - Edit::AddField { page, rect, kind, name } => { - pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; - } - Edit::SetFieldProps { name, props } => { - pdfcraft_forms::set_props(doc, name, props)?; - } - Edit::DuplicateField { name, pages } => { - if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { - return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); - } - } - Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, - Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, - Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, - // As after a script edit: the values the new order gives show at once. - Edit::SetCalculationOrder { names } => { - pdfcraft_forms::set_calculation_order(doc, names)?; - match cx.js.as_mut() { - Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, - None => pdfcraft_forms::recalculate(doc)?, - }; - } - Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?, - Edit::AutoTagText => { - a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::SetAltText { figure, alt } => { - let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); - a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::AutoTagFormFields => { - a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; - } - Edit::SetTagProperty { object, property, value } => { - a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::MoveTag { object, anchor, placement } => { - a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::EditTextLine { page, line, text } => { - pdfcraft_edit::replace_line(doc, *page, *line, text)?; - } - Edit::SetFieldScript { name, event, script } => { - pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; - match cx.js.as_mut() { - Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, - None => pdfcraft_forms::recalculate(doc)?, - }; - } - Edit::ConvertPdfA { level } => { - pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?; - } - Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?, - Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?, - Edit::AddOcrText { page, words } => { - pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; - } - Edit::EditPageImage { page, index, change } => { - let img = pdfcraft_edit::page_images(doc, *page)? - .into_iter() - .nth(*index) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; - let c = match change { - ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), - ImageEdit::Rotate(q) => { - pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) - } - ImageEdit::RotateBy(degrees) => { - let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; - pdfcraft_edit::ImageChange::Transform(t) - } - ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( - img.visible_rect.unwrap_or(img.rect), - 0, - *horizontal, - !*horizontal, - )), - ImageEdit::Replace { name, bytes } => { - if img.is_form { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( - "grouped Form artwork cannot be replaced with a bitmap".into(), - ))); - } - pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) - } - ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), - ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, - }; - pdfcraft_edit::change_image(doc, *page, *index, &c)?; - } - Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, - Edit::EditTextBlock { page, block, text, style } => { - pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; - } - Edit::RegisterTextFont { page, bytes } => { - pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; - } - Edit::MarkDecorative { figure } => { - let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); - a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::MarkTagArtifact { object } => { - a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::AddHeaderFooter { pages, settings, replace } => { - let date = cx.today; - pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; - } - Edit::AddWatermark { pages, settings, replace, file } => { - let mut s = settings.clone(); - if let Some(f) = file { - s.source = Some(mark_source(doc, f)?); - } - pdfcraft_edit::add_watermark(doc, pages, &s, *replace)? - } - Edit::AddBackground { pages, settings, replace, file } => { - let mut s = settings.clone(); - if let Some(f) = file { - s.source = Some(mark_source(doc, f)?); - } - pdfcraft_edit::add_background(doc, pages, &s, *replace)? - } - Edit::RemoveMarks { kind } => { - let n = pdfcraft_model::pages(doc).len(); - if pdfcraft_edit::remove_marks(doc, &(0..n).collect::>(), *kind)? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into()))); - } - } - Edit::AddText { page, text } => { - pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; - } - Edit::AddImage { page, rect, name, bytes } => { - let (image, natural) = embed_image(doc, name, bytes)?; - let rect = match rect { - Some(r) => *r, - None => { - let p = pdfcraft_model::pages(doc).swap_remove(*page); - let (pw, ph) = p.display_size(doc); - let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0); - let (w, h) = (natural.0 * k, natural.1 * k); - [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] - } - }; - pdfcraft_edit::add_content(doc, *page, &AddedContent::Image(pdfcraft_edit::AddedImage::new(rect, image)))?; - } - Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?, - Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?, - Edit::ReplaceImage { page, index, name, bytes } => { - let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); - let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); - }; - let (image, _) = embed_image(doc, name, bytes)?; - pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; - } - Edit::PasteContent { page, content, file } => { - let content = match content { - AddedContent::Text(_) => content.clone(), - AddedContent::Image(img) => { - let Some((name, bytes)) = file else { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into()))); - }; - // The same picture in this document (copied here, pasted here before, or an - // identical file): drawn again from the one image object rather than stored twice. - let image = if copy_image::is_picture(doc, img.image, bytes) { - img.image - } else { - match copy_image::added_picture(doc, bytes) { - Some(image) => image, - None => embed_image(doc, name, bytes)?.0, - } - }; - AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() }) - } - }; - pdfcraft_edit::add_content(doc, *page, &content)?; - } - Edit::ApplyRedactions { pages } => { - cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; - } - Edit::ClearRedactions => { - pdfcraft_redact::clear_marks(doc, None)?; - } - Edit::AddLink { page, rect, action, style } => { - pdfcraft_annot::links::add(doc, *page, *rect, action, style)?; - } - Edit::SetLink { page, index, rect, action, style } => pdfcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())?, - Edit::DeleteLink { page, index } => pdfcraft_annot::links::delete(doc, *page, *index)?, - Edit::RemoveLinks { pages } => { - if pdfcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there are no links to remove".into()))); - } - } - Edit::AddLinks { links, style } => { - if pdfcraft_annot::links::add_many(doc, links, style)? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); - } - } - Edit::ImportData { bytes, .. } => { - // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; - // XFDF, FDF, Acrobat's XML and tables go the usual way. - let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); - match text { - Some(Ok(text)) if is_xfa_data_file(&text) => { - let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); - let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) - .map_err(|e| EditError::Write(e.to_string()))?; - cx.xfa_datasets = r.stream.or(cx.xfa_datasets); - cx.xfa_out.errors.extend(r.warnings); - cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); - xfa_after_data_change(doc, cx, true)?; - } - Some(Err(why)) => return Err(EditError::Invalid(why)), - _ => { - pdfcraft_xfdf::import(doc, bytes)?; - } - } - } - Edit::RemoveHidden { which } => { - pdfcraft_redact::sanitize::remove_hidden(doc, which)?; - } - Edit::Sanitize => { - pdfcraft_redact::sanitize::sanitize(doc)?; - } - Edit::Flatten { comments, fields } => { - // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers - // would lay the form out again from its template over the baked pages. A dynamic - // form that was never laid out keeps its XFA: without it only placeholders remain. - let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; - match shape { - Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, - Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), - None => {} - } - } - Edit::XfaToStatic => { - let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { - return Err(EditError::Invalid("this document is not an XFA form".into())); - }; - if !shape.strippable() { - return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); - } - strip_xfa(doc, cx)?; - } - Edit::FlattenFillSign => { - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::>())?; - } - Edit::Protect(p) => { - p.validate()?; - let seed = cx.entropy(); - // Without a permissions password nothing is restricted, so the owner password is a - // random one nobody needs. - let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); - let params = pdfcraft_cos::NewEncryption { - algorithm: p.algorithm, - user_password: p.open_password.as_deref().unwrap_or(""), - owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner), - permissions: p.permission_bits(), - encrypt_metadata: p.encrypt_metadata, - seed, - }; - doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?; - } - Edit::RemoveProtection => { - if doc.output_handler().is_none() { - return Err(EditError::Protection("the document isn't protected".into())); - } - doc.remove_encryption(); - } - Edit::Batch { edits, .. } => { - for e in edits { - run_edit(doc, e, cx)?; - } - } - } - Ok(()) -} - /// The comment list, read from the object graph (as `inspect` lists comments). fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { let mut list: Vec = pdfcraft_annot::summaries(doc) @@ -2882,7 +1324,7 @@ pub fn guard(f: impl FnOnce() -> T) -> Result { } /// Parse another PDF to copy pages from. -fn open_source(name: &str, bytes: &Arc>) -> Result { +pub(crate) fn open_source(name: &str, bytes: &Arc>) -> Result { open_source_with(name, bytes, None) } @@ -2916,12 +1358,6 @@ pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option pdfcraft_organize::page_count(&doc).ok() } -/// The singular or the plural wording, by count. Both are written out: a language forms its plural -/// its own way, and `one` and `many` may differ in more than an "s". -fn plural(one: &'static str, many: &'static str, n: usize) -> Label { - Label::of(if n == 1 { one } else { many }) -} - #[derive(Default)] pub struct Session { docs: Vec, @@ -3061,7 +1497,7 @@ fn note_warnings(list: &mut Vec, new: &[String]) { /// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or /// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an /// encoding this can't read. -fn decode_data_file(bytes: &[u8]) -> Option> { +pub(crate) fn decode_data_file(bytes: &[u8]) -> Option> { if bytes.starts_with(b"%FDF") { return None; } @@ -3095,7 +1531,7 @@ fn decode_data_file(bytes: &[u8]) -> Option> { /// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, /// buttons that did nothing but run scripts, and fields whose value differs from the form's /// data (data PdfCraft could not read into them, now lost). -fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { +pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { let fields = pdfcraft_forms::fields(doc); let data = xfa_field_data(doc); let differ = pdfcraft_xfa::read_values(doc, &data) @@ -3150,7 +1586,7 @@ const XFA_REMOVED_PREFIX: &str = "XFA removed: "; /// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), /// rather than XFDF, Acrobat's `` XML or a table? By its root element. -fn is_xfa_data_file(text: &str) -> bool { +pub(crate) fn is_xfa_data_file(text: &str) -> bool { let text = text.get(..text.len().min(65_536)).unwrap_or(text); let mut rest = text.trim_start_matches('\u{feff}').trim_start(); // Past the XML declaration, comments and processing instructions. @@ -3174,7 +1610,7 @@ fn is_xfa_data_file(text: &str) -> bool { } /// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. -fn image_content_type(bytes: &[u8]) -> Option<&'static str> { +pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> { match pdfcraft_xfa::image::kind(bytes)? { pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), pdfcraft_xfa::image::Kind::Png => Some("image/png"), @@ -3187,7 +1623,7 @@ fn image_content_type(bytes: &[u8]) -> Option<&'static str> { /// After the XFA data changed outside the fields (an import, a picture): a laid-out form is /// laid out again from the data (then, when `calculate`, its calculations run), and the /// fields take the data's values. -fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { +pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { if let Some(tpl) = cx.xfa.clone() { let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; @@ -5142,7 +3578,7 @@ pub struct MarkFile { } /// Embed an image that is placed on a page, turned the way its EXIF Orientation says. -fn embed_image( +pub(crate) fn embed_image( doc: &mut pdfcraft_cos::Document, name: &str, bytes: &[u8], @@ -5154,7 +3590,7 @@ fn embed_image( /// shape in a box of any other shape. A box that is already that shape (to within rounding) is /// kept exactly, so callers that size it themselves get their box back. An empty or non-finite /// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse. -fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { +pub(crate) fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { if !rect.iter().all(|v| v.is_finite()) { return rect; } @@ -5174,7 +3610,7 @@ fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { } /// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. -fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { +pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { let head = &f.bytes[..f.bytes.len().min(1024)]; if head.windows(5).any(|w| w == b"%PDF-") { let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?; From 631fc13f13504c9d3395ce7410e6881e98d918ec Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:21:09 +0100 Subject: [PATCH 08/28] refactor(engine): move protection and the security summary to protection.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 158 +------------------------------- crates/engine/src/protection.rs | 158 ++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 155 deletions(-) create mode 100644 crates/engine/src/protection.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0926c3ed28..fdf57c87fa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -34,6 +34,7 @@ pub mod ocr; pub mod optimizer; pub mod print_raster; pub mod printcheck; +mod protection; mod scan_colours; mod scan_fields; pub mod signature_image; @@ -236,6 +237,8 @@ pub use history::Label; use history::{Editor, Keys, MAX_UNDO, Scope, TextBlocksCache, keys_after, scope_of, uses_scripts}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; +use protection::unrestricted; +pub use protection::{Changes, Printing, Protection, SecuritySummary}; /// Stable identifier of an open document within a session. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -1083,161 +1086,6 @@ pub struct RecoverySnapshot { pub encrypted: bool, } -/// Document Properties ▸ Security. -#[derive(Clone, Debug, PartialEq)] -pub struct SecuritySummary { - /// "AES, 256-bit" etc. - pub method: String, - /// Opened with the owner password (no restrictions apply). - pub owner: bool, - /// For pending protection: the restrictions as they will apply to others. - pub permissions: pdfcraft_cos::Permissions, - /// Set in this session; written by the next save. - pub pending: bool, -} - -/// What printing a protected document allows (Acrobat: Printing allowed). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Printing { - None, - /// "Low Resolution (150 dpi)". - Low, - High, -} - -/// What changes a protected document allows (Acrobat: Changes allowed). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Changes { - None, - /// "Inserting, deleting, and rotating pages". - Pages, - /// "Filling in form fields and signing existing signature fields". - FillSign, - /// "Commenting, filling in form fields, and signing existing signature fields". - CommentFillSign, - /// "Any except extracting pages". - AnyExceptExtract, -} - -/// Password protection to apply (Protect Using Password and its Advanced options). -#[derive(Clone, PartialEq)] -pub struct Protection { - /// Required to open the document (the user password). - pub open_password: Option, - /// Required to change security and lift the restrictions below (the owner password). - pub permissions_password: Option, - pub printing: Printing, - pub changes: Changes, - /// "Enable copying of text, images, and other content". - pub copy: bool, - /// "Enable text access for screen reader devices". - pub accessibility: bool, - /// Compatibility level; AES-256 (Acrobat X and later) by default. - pub algorithm: pdfcraft_cos::Algorithm, - /// `false`: "Encrypt all document contents except metadata". - pub encrypt_metadata: bool, -} - -impl std::fmt::Debug for Protection { - // Never print passwords (edits end up in logs and error messages). - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Protection") - .field("open_password", &self.open_password.as_ref().map(|_| "…")) - .field("permissions_password", &self.permissions_password.as_ref().map(|_| "…")) - .field("printing", &self.printing) - .field("changes", &self.changes) - .field("copy", &self.copy) - .field("accessibility", &self.accessibility) - .field("algorithm", &self.algorithm) - .field("encrypt_metadata", &self.encrypt_metadata) - .finish() - } -} - -impl Default for Protection { - /// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying. - fn default() -> Self { - Self { - open_password: None, - permissions_password: None, - printing: Printing::High, - changes: Changes::None, - copy: false, - accessibility: true, - algorithm: pdfcraft_cos::Algorithm::Aes256, - encrypt_metadata: true, - } - } -} - -impl Protection { - /// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices. - pub fn permission_bits(&self) -> i32 { - let bit = |n: u32| 1i32 << (n - 1); - if self.permissions_password.is_none() { - return -1; // nothing restricted without a permissions password - } - let mut p = 0; - match self.printing { - Printing::None => {} - Printing::Low => p |= bit(3), - Printing::High => p |= bit(3) | bit(12), - } - p |= match self.changes { - Changes::None => 0, - Changes::Pages => bit(11), - Changes::FillSign => bit(9), - Changes::CommentFillSign => bit(6) | bit(9), - Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9), - }; - if self.copy { - p |= bit(5) | bit(10); - } - if self.accessibility { - p |= bit(10); - } - p - } - - fn validate(&self) -> Result<(), EditError> { - let bad = |m: &str| Err(EditError::Protection(m.to_string())); - match (&self.open_password, &self.permissions_password) { - (None, None) => return bad("enter a password to open the document, a permissions password, or both"), - (Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"), - _ => {} - } - if [&self.open_password, &self.permissions_password].into_iter().flatten().any(String::is_empty) { - return bad("passwords can't be empty"); - } - if self.algorithm != pdfcraft_cos::Algorithm::Aes256 - && [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c))) - { - return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)"); - } - // Encryption keys on a password as `effective_password` gives it (AES-256: SASLprep, - // which drops soft hyphens and folds compatibility forms; older levels: the first 32 - // bytes, padded). One that comes out as the empty password opens the file without asking, - // and an open password that comes out as the permissions password lifts the restrictions. - let effective = |p: &str| pdfcraft_cos::effective_password(self.algorithm, p); - let (open, permissions) = (self.open_password.as_deref().map(effective), self.permissions_password.as_deref().map(effective)); - let empty = effective(""); - if open.iter().chain(&permissions).any(|p| *p == empty) { - return bad("passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens"); - } - if open.is_some() && open == permissions { - return bad("the open password and the permissions password must be different (PDF readers treat these two as the same password)"); - } - Ok(()) - } -} - -/// Opened as owner, or nothing is restricted (no permissions password was set): security may -/// be changed, as in Acrobat. -pub(crate) fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { - const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12 - p.owner || p.bits & ALL == ALL -} - /// The comment list, read from the object graph (as `inspect` lists comments). fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { let mut list: Vec = pdfcraft_annot::summaries(doc) diff --git a/crates/engine/src/protection.rs b/crates/engine/src/protection.rs new file mode 100644 index 0000000000..e66f01538a --- /dev/null +++ b/crates/engine/src/protection.rs @@ -0,0 +1,158 @@ +//! Password protection to apply, and the security a document has. + +use crate::EditError; + +/// Document Properties ▸ Security. +#[derive(Clone, Debug, PartialEq)] +pub struct SecuritySummary { + /// "AES, 256-bit" etc. + pub method: String, + /// Opened with the owner password (no restrictions apply). + pub owner: bool, + /// For pending protection: the restrictions as they will apply to others. + pub permissions: pdfcraft_cos::Permissions, + /// Set in this session; written by the next save. + pub pending: bool, +} + +/// What printing a protected document allows (Acrobat: Printing allowed). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Printing { + None, + /// "Low Resolution (150 dpi)". + Low, + High, +} + +/// What changes a protected document allows (Acrobat: Changes allowed). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Changes { + None, + /// "Inserting, deleting, and rotating pages". + Pages, + /// "Filling in form fields and signing existing signature fields". + FillSign, + /// "Commenting, filling in form fields, and signing existing signature fields". + CommentFillSign, + /// "Any except extracting pages". + AnyExceptExtract, +} + +/// Password protection to apply (Protect Using Password and its Advanced options). +#[derive(Clone, PartialEq)] +pub struct Protection { + /// Required to open the document (the user password). + pub open_password: Option, + /// Required to change security and lift the restrictions below (the owner password). + pub permissions_password: Option, + pub printing: Printing, + pub changes: Changes, + /// "Enable copying of text, images, and other content". + pub copy: bool, + /// "Enable text access for screen reader devices". + pub accessibility: bool, + /// Compatibility level; AES-256 (Acrobat X and later) by default. + pub algorithm: pdfcraft_cos::Algorithm, + /// `false`: "Encrypt all document contents except metadata". + pub encrypt_metadata: bool, +} + +impl std::fmt::Debug for Protection { + // Never print passwords (edits end up in logs and error messages). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Protection") + .field("open_password", &self.open_password.as_ref().map(|_| "…")) + .field("permissions_password", &self.permissions_password.as_ref().map(|_| "…")) + .field("printing", &self.printing) + .field("changes", &self.changes) + .field("copy", &self.copy) + .field("accessibility", &self.accessibility) + .field("algorithm", &self.algorithm) + .field("encrypt_metadata", &self.encrypt_metadata) + .finish() + } +} + +impl Default for Protection { + /// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying. + fn default() -> Self { + Self { + open_password: None, + permissions_password: None, + printing: Printing::High, + changes: Changes::None, + copy: false, + accessibility: true, + algorithm: pdfcraft_cos::Algorithm::Aes256, + encrypt_metadata: true, + } + } +} + +impl Protection { + /// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices. + pub fn permission_bits(&self) -> i32 { + let bit = |n: u32| 1i32 << (n - 1); + if self.permissions_password.is_none() { + return -1; // nothing restricted without a permissions password + } + let mut p = 0; + match self.printing { + Printing::None => {} + Printing::Low => p |= bit(3), + Printing::High => p |= bit(3) | bit(12), + } + p |= match self.changes { + Changes::None => 0, + Changes::Pages => bit(11), + Changes::FillSign => bit(9), + Changes::CommentFillSign => bit(6) | bit(9), + Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9), + }; + if self.copy { + p |= bit(5) | bit(10); + } + if self.accessibility { + p |= bit(10); + } + p + } + + pub(crate) fn validate(&self) -> Result<(), EditError> { + let bad = |m: &str| Err(EditError::Protection(m.to_string())); + match (&self.open_password, &self.permissions_password) { + (None, None) => return bad("enter a password to open the document, a permissions password, or both"), + (Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"), + _ => {} + } + if [&self.open_password, &self.permissions_password].into_iter().flatten().any(String::is_empty) { + return bad("passwords can't be empty"); + } + if self.algorithm != pdfcraft_cos::Algorithm::Aes256 + && [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c))) + { + return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)"); + } + // Encryption keys on a password as `effective_password` gives it (AES-256: SASLprep, + // which drops soft hyphens and folds compatibility forms; older levels: the first 32 + // bytes, padded). One that comes out as the empty password opens the file without asking, + // and an open password that comes out as the permissions password lifts the restrictions. + let effective = |p: &str| pdfcraft_cos::effective_password(self.algorithm, p); + let (open, permissions) = (self.open_password.as_deref().map(effective), self.permissions_password.as_deref().map(effective)); + let empty = effective(""); + if open.iter().chain(&permissions).any(|p| *p == empty) { + return bad("passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens"); + } + if open.is_some() && open == permissions { + return bad("the open password and the permissions password must be different (PDF readers treat these two as the same password)"); + } + Ok(()) + } +} + +/// Opened as owner, or nothing is restricted (no permissions password was set): security may +/// be changed, as in Acrobat. +pub(crate) fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { + const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12 + p.owner || p.bits & ALL == ALL +} From 05098de5421130dfe2ae013b4e6e1f06afa5008e Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:22:07 +0100 Subject: [PATCH 09/28] refactor(engine): move the open pipeline to open.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 345 +----------------------------------- crates/engine/src/open.rs | 355 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 358 insertions(+), 342 deletions(-) create mode 100644 crates/engine/src/open.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fdf57c87fa..ac836aabc1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -31,6 +31,7 @@ mod history; pub mod js; pub mod links; pub mod ocr; +mod open; pub mod optimizer; pub mod print_raster; pub mod printcheck; @@ -234,7 +235,8 @@ pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; use edit::{EditCtx, check_permission, run_edit}; pub use error::{EditError, SourceProblem}; pub use history::Label; -use history::{Editor, Keys, MAX_UNDO, Scope, TextBlocksCache, keys_after, scope_of, uses_scripts}; +use history::{Editor, Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts}; +pub use open::{OpenContext, PreparedDocument}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; use protection::unrestricted; @@ -1230,35 +1232,6 @@ pub struct Session { xfa_time_scale: Option, } -/// Immutable session state needed while a document is prepared away from its owning thread. -#[derive(Clone)] -pub struct OpenContext { - now: Option, - js_off: bool, - trust: Arc, - xfa_time_scale: u32, -} - -/// A fully prepared document that has not yet been registered in a session. -pub struct PreparedDocument { - document: Document, -} - -impl OpenContext { - /// Prepare a document without mutating the session that supplied this context. - pub fn prepare_open( - &self, - name: impl Into, - path: Option, - bytes: Arc>, - password: Option<&str>, - ) -> Result { - let name = name.into(); - guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password)) - .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) - } -} - /// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; @@ -1649,318 +1622,6 @@ impl Session { self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) } - /// Snapshot the session state that affects open-time scripts, dates and signature trust. - pub fn open_context(&self) -> OpenContext { - OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) } - } - - /// Prepare a document without adding it to this session. - pub fn prepare_open( - &self, - name: impl Into, - path: Option, - bytes: Arc>, - password: Option<&str>, - ) -> Result { - self.open_context().prepare_open(name, path, bytes, password) - } - - /// Register a prepared document. This is the only open step that mutates the session. - pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId { - self.next_id += 1; - let id = DocId(self.next_id); - let mut document = prepared.document; - document.id = id; - self.docs.push(document); - id - } - - /// Synchronous convenience API used by the CLI and automation. - pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { - let prepared = self.prepare_open(name, path, bytes, password)?; - Ok(self.commit_open(prepared)) - } - - fn prepare_open_unguarded( - context: &OpenContext, - name: String, - path: Option, - bytes: Arc>, - password: Option<&str>, - ) -> Result { - let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)); - // The renderer authenticates on its own. It cannot use the owner password of R2–R4 - // files, so give it the user password that owner authentication recovers. - let (info, render_password) = match inspect(bytes.clone(), password) { - Ok(info) => (info, password.map(str::to_owned)), - Err(OpenError::WrongPassword) => { - let user = match &cos { - Ok(Ok(d)) => recovered_render_password(d), - _ => None, - }; - let user = user.ok_or(OpenError::WrongPassword)?; - (inspect(bytes.clone(), Some(&user))?, Some(user)) - } - Err(e) => return Err(e), - }; - // A dynamic XFA form is a shell around an XML template; lay the template out into real - // pages and fields so the rest of the engine works on it. The original bytes stay: the - // laid-out form is one appended revision. - let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - // Write `work` as one more revision and reopen it: what the document then is. - let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { - let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; - let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; - let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?; - Ok((new_bytes, new_info, new_cos)) - }; - let (bytes, info, cos, xfa) = match (info.xfa, cos) { - (Some(pdfcraft_render::Xfa::Dynamic), Ok(Ok(cos))) if pdfcraft_xfa::existing_layout(&cos).is_none() => { - let mut work = cos.clone(); - let laid_out = guard(|| xfa_layout(&mut work)) - .unwrap_or_else(|m| Err(format!("laying it out failed unexpectedly ({m})"))) - .and_then(|report| rebase(&work).map(|(b, i, c)| (b, i, c, report))); - match laid_out { - Ok((b, i, c, report)) => (b, i, Ok(Ok(c)), Some(report)), - Err(e) => { - let mut info = info; - info.warnings.push(format!("This dynamic XFA form could not be laid out: {e}")); - (bytes, info, Ok(Ok(cos)), None) - } - } - } - // A static XFA form, or a dynamic one laid out earlier: its datasets may hold values - // filled in by another viewer since; give the fields those values. - (Some(_), Ok(Ok(cos))) => { - let report = pdfcraft_xfa::existing_layout(&cos); - let mut work = cos.clone(); - let synced = - guard(|| xfa_values_from_datasets(&mut work)).unwrap_or_else(|m| Err(format!("reading its data failed unexpectedly ({m})"))); - match synced { - Ok(names) if names.is_empty() => (bytes, info, Ok(Ok(cos)), report), - Ok(names) => match rebase(&work) { - Ok((b, mut i, c)) => { - // The fields were rewritten from the XFA data: say so, and which. - let shown: Vec<&str> = names.iter().take(5).map(String::as_str).collect(); - let more = if names.len() > shown.len() { format!(" and {} more", names.len() - shown.len()) } else { String::new() }; - i.warnings.push(format!( - "{} form field value{} were taken from this form's XFA data (filled in by another viewer): {}{more}", - names.len(), - if names.len() == 1 { "" } else { "s" }, - shown.join(", ") - )); - (b, i, Ok(Ok(c)), report) - } - Err(e) => { - let mut info = info; - info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); - (bytes, info, Ok(Ok(cos)), report) - } - }, - Err(e) => { - let mut info = info; - info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); - (bytes, info, Ok(Ok(cos)), report) - } - } - } - (_, cos) => (bytes, info, cos, None), - }; - // A laid-out XFA form: its initialize and calculate scripts run now, as on opening in - // Acrobat; what they change is one more revision, still nothing to save. - let mut script_output = js::JsOutput::default(); - let mut ran_away = false; - let xfa_template = match (&xfa, &cos) { - (Some(_), Ok(Ok(c))) => xfa::template(c), - _ => None, - }; - let (bytes, info, cos) = match (&xfa, cos) { - (Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() { - Some(tpl) => { - let mut work = cos.clone(); - let pages = info.pages.len(); - let ran = guard(|| { - let mut run = xfa::XfaRun { - page_count: pages, - out: &mut script_output, - datasets: None, - ran_away: false, - time_scale: context.xfa_time_scale, - }; - let changes = xfa::on_open(&mut work, &tpl, &mut run); - if run.ran_away { - ran_away = true; - } - changes - }) - .unwrap_or_else(|m| Err(format!("its scripts failed unexpectedly ({m})"))); - match ran { - Ok(changes) if work.is_modified() => match rebase(&work) { - Ok((b, mut i, c)) => { - // The file as saved differs from what opening shows: say so. - if !changes.is_empty() { - i.warnings.push(format!( - "This form's scripts changed it on opening ({}); saving keeps those changes", - changes.describe() - )); - } - (b, i, Ok(Ok(c))) - } - Err(e) => { - script_output.errors.push(format!("The form's scripts could not be applied: {e}")); - (bytes, info, Ok(Ok(cos))) - } - }, - Ok(_) => (bytes, info, Ok(Ok(cos))), - Err(e) => { - script_output.errors.push(format!("The form's scripts could not run: {e}")); - (bytes, info, Ok(Ok(cos))) - } - } - } - None => (bytes, info, Ok(Ok(cos))), - }, - (_, cos) => (bytes, info, cos), - }; - // The catalog leads to no page, but the reader found pages by scanning: gather the page - // objects into a page tree as one more revision, or page edits address none of them - // (#1656). Kept only when the reader then shows the same pages, and never on a signed - // document, where writing the repair could rewrite the file under its signatures. - let (bytes, info, cos) = match cos { - Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => { - let mut work = cos.clone(); - let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed); - let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) { - Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)), - _ => None, - }; - match rebuilt { - Some((b, i, mut c)) => { - for line in cos.repair_log() { - c.note_repair(line.clone()); - } - c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len())); - (b, i, Ok(Ok(c))) - } - None => (bytes, info, Ok(Ok(cos))), - } - } - cos => (bytes, info, cos), - }; - // XFA notes survive the document being re-read after edits. - let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; - let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); - // A script that ran away on open turns the form's scripts off for this document. - document.xfa_template = if ran_away { None } else { xfa_template }; - document.js_output.append(script_output); - if ran_away { - document.xfa_scripts_off = true; - document.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } - note_warnings(&mut document.xfa_warnings, &xfa_warnings); - Ok(PreparedDocument { document }) - } - - /// The last step of opening: build the document record and register it. - #[allow(clippy::too_many_arguments)] - fn build_document( - trust: &Arc, - name: String, - path: Option, - bytes: Arc>, - mut info: DocInfo, - cos: Result, Box>, - render_password: Option, - password: Option<&str>, - xfa: Option, - ) -> Document { - let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; - let (mut editor, read_only_reason) = match cos { - Ok(Ok(cos)) => { - let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) }; - let saved = cos.bytes().clone(); - ( - Some(Editor { - cos, - saved, - saved_keys: keys.clone(), - unsaved_file: false, - undo: Vec::new(), - redo: Vec::new(), - keys, - appearances: AppearanceCache::default(), - field_appearances: pdfcraft_forms::FieldAppearanceCache::default(), - text_blocks: TextBlocksCache::default(), - }), - None, - ) - } - Ok(Err(e)) => (None, Some(e.to_string())), - Err(_) => (None, Some("the document structure could not be read for editing".into())), - }; - let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); - let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); - // The inspection reads only `/Info`; a file stating its title or author only in XMP still - // shows them (#2373). - if let Some(e) = &editor { - for (field, value) in info_fields(&mut info, &e.cos) { - if field.is_none() { - *field = value; - } - } - } - let display = display_bytes(editor.as_mut(), &bytes); - let renderer = RenderPool::new(display.clone(), render_threads(), config.clone()); - let (form, adopted) = editor - .as_ref() - .map(|e| { - let mut fields = pdfcraft_forms::fields_with_adopted(&e.cos); - xfa::mark_script_buttons(&e.cos, &mut fields.items); - (fields.items, fields.adopted_page_fields) - }) - .unwrap_or_default(); - // Fields the file lists only as page widgets are adopted leniently; the file says so. - if adopted > 0 - && let Some(e) = &mut editor - { - let line = if adopted == 1 { - "the form's /Fields list omits 1 field; it was read from the page annotations".to_string() - } else { - format!("the form's /Fields list omits {adopted} fields; they were read from the page annotations") - }; - e.cos.note_repair(line); - } - Document { - id: DocId(0), - name, - path, - bytes, - display, - info, - renderer, - password: render_password, - dirty: false, - generation: 0, - snapshot_generation: 0, - read_only_reason, - form: Arc::new(form), - marker_scan: OnceLock::new(), - links: OnceLock::new(), - signatures: Arc::new(signatures), - trust: trust.clone(), - sig_cache, - editor, - config, - js_output: Default::default(), - xfa, - xfa_template: None, - xfa_scripts_off: false, - fonts_stale: false, - xfa_warnings: Vec::new(), - edit_notices: Vec::new(), - } - } - fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) } diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs new file mode 100644 index 0000000000..d8950d1d6d --- /dev/null +++ b/crates/engine/src/open.rs @@ -0,0 +1,355 @@ +//! Opening a document: preparing it away from the session, then registering it. + +use std::sync::{Arc, OnceLock}; + +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect}; + +use crate::history::{Editor, Keys, TextBlocksCache}; +use crate::{ + AppearanceCache, DocId, Document, Session, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, guard, info_fields, js, note_warnings, + recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets, +}; + +/// Immutable session state needed while a document is prepared away from its owning thread. +#[derive(Clone)] +pub struct OpenContext { + now: Option, + js_off: bool, + trust: Arc, + xfa_time_scale: u32, +} + +/// A fully prepared document that has not yet been registered in a session. +pub struct PreparedDocument { + document: Document, +} + +impl OpenContext { + /// Prepare a document without mutating the session that supplied this context. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + let name = name.into(); + guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password)) + .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) + } +} + +impl Session { + /// Snapshot the session state that affects open-time scripts, dates and signature trust. + pub fn open_context(&self) -> OpenContext { + OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) } + } + + /// Prepare a document without adding it to this session. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + self.open_context().prepare_open(name, path, bytes, password) + } + + /// Register a prepared document. This is the only open step that mutates the session. + pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId { + self.next_id += 1; + let id = DocId(self.next_id); + let mut document = prepared.document; + document.id = id; + self.docs.push(document); + id + } + + /// Synchronous convenience API used by the CLI and automation. + pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { + let prepared = self.prepare_open(name, path, bytes, password)?; + Ok(self.commit_open(prepared)) + } + + fn prepare_open_unguarded( + context: &OpenContext, + name: String, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)); + // The renderer authenticates on its own. It cannot use the owner password of R2–R4 + // files, so give it the user password that owner authentication recovers. + let (info, render_password) = match inspect(bytes.clone(), password) { + Ok(info) => (info, password.map(str::to_owned)), + Err(OpenError::WrongPassword) => { + let user = match &cos { + Ok(Ok(d)) => recovered_render_password(d), + _ => None, + }; + let user = user.ok_or(OpenError::WrongPassword)?; + (inspect(bytes.clone(), Some(&user))?, Some(user)) + } + Err(e) => return Err(e), + }; + // A dynamic XFA form is a shell around an XML template; lay the template out into real + // pages and fields so the rest of the engine works on it. The original bytes stay: the + // laid-out form is one appended revision. + let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + // Write `work` as one more revision and reopen it: what the document then is. + let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { + let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; + let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; + let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?; + Ok((new_bytes, new_info, new_cos)) + }; + let (bytes, info, cos, xfa) = match (info.xfa, cos) { + (Some(pdfcraft_render::Xfa::Dynamic), Ok(Ok(cos))) if pdfcraft_xfa::existing_layout(&cos).is_none() => { + let mut work = cos.clone(); + let laid_out = guard(|| xfa_layout(&mut work)) + .unwrap_or_else(|m| Err(format!("laying it out failed unexpectedly ({m})"))) + .and_then(|report| rebase(&work).map(|(b, i, c)| (b, i, c, report))); + match laid_out { + Ok((b, i, c, report)) => (b, i, Ok(Ok(c)), Some(report)), + Err(e) => { + let mut info = info; + info.warnings.push(format!("This dynamic XFA form could not be laid out: {e}")); + (bytes, info, Ok(Ok(cos)), None) + } + } + } + // A static XFA form, or a dynamic one laid out earlier: its datasets may hold values + // filled in by another viewer since; give the fields those values. + (Some(_), Ok(Ok(cos))) => { + let report = pdfcraft_xfa::existing_layout(&cos); + let mut work = cos.clone(); + let synced = + guard(|| xfa_values_from_datasets(&mut work)).unwrap_or_else(|m| Err(format!("reading its data failed unexpectedly ({m})"))); + match synced { + Ok(names) if names.is_empty() => (bytes, info, Ok(Ok(cos)), report), + Ok(names) => match rebase(&work) { + Ok((b, mut i, c)) => { + // The fields were rewritten from the XFA data: say so, and which. + let shown: Vec<&str> = names.iter().take(5).map(String::as_str).collect(); + let more = if names.len() > shown.len() { format!(" and {} more", names.len() - shown.len()) } else { String::new() }; + i.warnings.push(format!( + "{} form field value{} were taken from this form's XFA data (filled in by another viewer): {}{more}", + names.len(), + if names.len() == 1 { "" } else { "s" }, + shown.join(", ") + )); + (b, i, Ok(Ok(c)), report) + } + Err(e) => { + let mut info = info; + info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); + (bytes, info, Ok(Ok(cos)), report) + } + }, + Err(e) => { + let mut info = info; + info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); + (bytes, info, Ok(Ok(cos)), report) + } + } + } + (_, cos) => (bytes, info, cos, None), + }; + // A laid-out XFA form: its initialize and calculate scripts run now, as on opening in + // Acrobat; what they change is one more revision, still nothing to save. + let mut script_output = js::JsOutput::default(); + let mut ran_away = false; + let xfa_template = match (&xfa, &cos) { + (Some(_), Ok(Ok(c))) => xfa::template(c), + _ => None, + }; + let (bytes, info, cos) = match (&xfa, cos) { + (Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() { + Some(tpl) => { + let mut work = cos.clone(); + let pages = info.pages.len(); + let ran = guard(|| { + let mut run = xfa::XfaRun { + page_count: pages, + out: &mut script_output, + datasets: None, + ran_away: false, + time_scale: context.xfa_time_scale, + }; + let changes = xfa::on_open(&mut work, &tpl, &mut run); + if run.ran_away { + ran_away = true; + } + changes + }) + .unwrap_or_else(|m| Err(format!("its scripts failed unexpectedly ({m})"))); + match ran { + Ok(changes) if work.is_modified() => match rebase(&work) { + Ok((b, mut i, c)) => { + // The file as saved differs from what opening shows: say so. + if !changes.is_empty() { + i.warnings.push(format!( + "This form's scripts changed it on opening ({}); saving keeps those changes", + changes.describe() + )); + } + (b, i, Ok(Ok(c))) + } + Err(e) => { + script_output.errors.push(format!("The form's scripts could not be applied: {e}")); + (bytes, info, Ok(Ok(cos))) + } + }, + Ok(_) => (bytes, info, Ok(Ok(cos))), + Err(e) => { + script_output.errors.push(format!("The form's scripts could not run: {e}")); + (bytes, info, Ok(Ok(cos))) + } + } + } + None => (bytes, info, Ok(Ok(cos))), + }, + (_, cos) => (bytes, info, cos), + }; + // The catalog leads to no page, but the reader found pages by scanning: gather the page + // objects into a page tree as one more revision, or page edits address none of them + // (#1656). Kept only when the reader then shows the same pages, and never on a signed + // document, where writing the repair could rewrite the file under its signatures. + let (bytes, info, cos) = match cos { + Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => { + let mut work = cos.clone(); + let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed); + let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) { + Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)), + _ => None, + }; + match rebuilt { + Some((b, i, mut c)) => { + for line in cos.repair_log() { + c.note_repair(line.clone()); + } + c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len())); + (b, i, Ok(Ok(c))) + } + None => (bytes, info, Ok(Ok(cos))), + } + } + cos => (bytes, info, cos), + }; + // XFA notes survive the document being re-read after edits. + let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; + let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); + // A script that ran away on open turns the form's scripts off for this document. + document.xfa_template = if ran_away { None } else { xfa_template }; + document.js_output.append(script_output); + if ran_away { + document.xfa_scripts_off = true; + document.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } + note_warnings(&mut document.xfa_warnings, &xfa_warnings); + Ok(PreparedDocument { document }) + } + + /// The last step of opening: build the document record and register it. + #[allow(clippy::too_many_arguments)] + fn build_document( + trust: &Arc, + name: String, + path: Option, + bytes: Arc>, + mut info: DocInfo, + cos: Result, Box>, + render_password: Option, + password: Option<&str>, + xfa: Option, + ) -> Document { + let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; + let (mut editor, read_only_reason) = match cos { + Ok(Ok(cos)) => { + let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) }; + let saved = cos.bytes().clone(); + ( + Some(Editor { + cos, + saved, + saved_keys: keys.clone(), + unsaved_file: false, + undo: Vec::new(), + redo: Vec::new(), + keys, + appearances: AppearanceCache::default(), + field_appearances: pdfcraft_forms::FieldAppearanceCache::default(), + text_blocks: TextBlocksCache::default(), + }), + None, + ) + } + Ok(Err(e)) => (None, Some(e.to_string())), + Err(_) => (None, Some("the document structure could not be read for editing".into())), + }; + let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); + let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); + // The inspection reads only `/Info`; a file stating its title or author only in XMP still + // shows them (#2373). + if let Some(e) = &editor { + for (field, value) in info_fields(&mut info, &e.cos) { + if field.is_none() { + *field = value; + } + } + } + let display = display_bytes(editor.as_mut(), &bytes); + let renderer = RenderPool::new(display.clone(), render_threads(), config.clone()); + let (form, adopted) = editor + .as_ref() + .map(|e| { + let mut fields = pdfcraft_forms::fields_with_adopted(&e.cos); + xfa::mark_script_buttons(&e.cos, &mut fields.items); + (fields.items, fields.adopted_page_fields) + }) + .unwrap_or_default(); + // Fields the file lists only as page widgets are adopted leniently; the file says so. + if adopted > 0 + && let Some(e) = &mut editor + { + let line = if adopted == 1 { + "the form's /Fields list omits 1 field; it was read from the page annotations".to_string() + } else { + format!("the form's /Fields list omits {adopted} fields; they were read from the page annotations") + }; + e.cos.note_repair(line); + } + Document { + id: DocId(0), + name, + path, + bytes, + display, + info, + renderer, + password: render_password, + dirty: false, + generation: 0, + snapshot_generation: 0, + read_only_reason, + form: Arc::new(form), + marker_scan: OnceLock::new(), + links: OnceLock::new(), + signatures: Arc::new(signatures), + trust: trust.clone(), + sig_cache, + editor, + config, + js_output: Default::default(), + xfa, + xfa_template: None, + xfa_scripts_off: false, + fonts_stale: false, + xfa_warnings: Vec::new(), + edit_notices: Vec::new(), + } + } +} From f1489157303bdc831ac606405905f074aaad6582 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:22:54 +0100 Subject: [PATCH 10/28] refactor(engine): move the other crates' re-exports to reexports.rs The doc comment for SplitPart had drifted above the LabelStyle re-export; it is back on SplitPart. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 53 ++-------------------------------- crates/engine/src/reexports.rs | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 50 deletions(-) create mode 100644 crates/engine/src/reexports.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ac836aabc1..6edc4d7b7e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,6 +36,7 @@ pub mod optimizer; pub mod print_raster; pub mod printcheck; mod protection; +mod reexports; mod scan_colours; mod scan_fields; pub mod signature_image; @@ -51,36 +52,7 @@ pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use stamp_preview::StampPreview; pub use text_preview::TextEditPreview; -pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; - -/// One file produced by a split: (1-based first page, last page, PDF bytes). -pub use pdfcraft_organize::LabelStyle; -pub use pdfcraft_organize::Standard; -pub use pdfcraft_organize::check_custom_key; -pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; - -pub use pdfcraft_cos::Algorithm; -pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; -pub use pdfcraft_edit::added::{ - CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, - text_width as added_text_width, -}; -pub use pdfcraft_edit::{ - Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, -}; -pub use pdfcraft_forms::{ - BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, - FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, - Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, - calculations_changed as form_calculations_changed, flags as field_flags, -}; - -pub use pdfcraft_a11y as a11y; -pub use pdfcraft_edit::{BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable}; -pub use pdfcraft_measure as measure; -pub use pdfcraft_xfa::Report as XfaLayout; - -pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; +pub use reexports::*; /// A change to an existing page image. #[derive(Clone, Debug, PartialEq)] @@ -103,7 +75,6 @@ pub enum ImageEdit { }, Delete, } -pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; /// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user /// space, vertically centred) and `height` points tall. `None` for text with no outlines. @@ -151,25 +122,6 @@ pub fn typed_signature_graphic(text: &str) -> Option { pub use links::{ MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, }; -/// Comment geometry helpers (text-box line breaking) for frontends. -pub use pdfcraft_annot::appearance as annot_text; -pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; -pub use pdfcraft_annot::{ - AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, - ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, -}; -pub use pdfcraft_forms::detect; -pub use pdfcraft_optimize as optimize; -pub use pdfcraft_preflight as pdfa; -pub use pdfcraft_print as print; -pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet}; -pub use pdfcraft_redact::patterns::{ - MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, -}; -pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; -pub use pdfcraft_sign as sign; -pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; -pub use pdfcraft_xfdf::Format as DataFormat; /// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a /// filled-in PDF form), as CSV with one row per file. @@ -179,6 +131,7 @@ pub fn merge_data_files(files: &[(String, Vec)]) -> Result { Ok(pdfcraft_xfdf::merge_csv(&rows)) } +/// One file produced by a split: (1-based first page, last page, PDF bytes). pub type SplitPart = (usize, usize, Arc>); use std::sync::{Arc, OnceLock}; diff --git a/crates/engine/src/reexports.rs b/crates/engine/src/reexports.rs new file mode 100644 index 0000000000..208a8e450b --- /dev/null +++ b/crates/engine/src/reexports.rs @@ -0,0 +1,53 @@ +//! What the engine re-exports from the other pdfcraft crates, so frontends need only this one. + +pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; + +pub use pdfcraft_organize::LabelStyle; +pub use pdfcraft_organize::Standard; +pub use pdfcraft_organize::check_custom_key; +pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; + +pub use pdfcraft_cos::Algorithm; +pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; +pub use pdfcraft_edit::added::{ + CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, + text_width as added_text_width, +}; +pub use pdfcraft_edit::{ + Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, +}; +pub use pdfcraft_forms::{ + BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, + FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, + Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, + calculations_changed as form_calculations_changed, flags as field_flags, +}; + +pub use pdfcraft_a11y as a11y; +pub use pdfcraft_edit::{BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable}; +pub use pdfcraft_measure as measure; +pub use pdfcraft_xfa::Report as XfaLayout; + +pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; + +pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; + +/// Comment geometry helpers (text-box line breaking) for frontends. +pub use pdfcraft_annot::appearance as annot_text; +pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; +pub use pdfcraft_annot::{ + AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, + ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, +}; +pub use pdfcraft_forms::detect; +pub use pdfcraft_optimize as optimize; +pub use pdfcraft_preflight as pdfa; +pub use pdfcraft_print as print; +pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet}; +pub use pdfcraft_redact::patterns::{ + MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, +}; +pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; +pub use pdfcraft_sign as sign; +pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; +pub use pdfcraft_xfdf::Format as DataFormat; From 5b7518075950d1b2da929ffe79ba9d46dd8c8f62 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:24:44 +0100 Subject: [PATCH 11/28] refactor(engine): move Session and its operations to session.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 1581 +-------------------------------- crates/engine/src/session.rs | 1590 ++++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 2 + 3 files changed, 1598 insertions(+), 1575 deletions(-) create mode 100644 crates/engine/src/session.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6edc4d7b7e..91d22accd7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -39,6 +39,7 @@ mod protection; mod reexports; mod scan_colours; mod scan_fields; +mod session; pub mod signature_image; pub mod signature_scan; mod stamp_preview; @@ -184,16 +185,17 @@ fn record_link_scan() { }); } +use edit::EditCtx; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; -use edit::{EditCtx, check_permission, run_edit}; pub use error::{EditError, SourceProblem}; +use history::Editor; pub use history::Label; -use history::{Editor, Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts}; pub use open::{OpenContext, PreparedDocument}; -use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; -use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{DocInfo, Layer, LayerOp, RenderConfig, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; +pub use session::Session; /// Stable identifier of an open document within a session. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -1161,30 +1163,6 @@ pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option pdfcraft_organize::page_count(&doc).ok() } -#[derive(Default)] -pub struct Session { - docs: Vec, - next_id: u64, - /// Seconds since the Unix epoch, injected so saves are deterministic in tests. - clock: Option i64>, - /// Seconds to add to UTC for local time, read from whoever knows the zone (a browser, say). - /// Separate from `clock` on purpose: `clock` alone is the test shortcut that takes the - /// injected time AS local time and zeroes the zone, so injecting only it leaves either the - /// local dates or the UTC ones wrong by the zone's offset. - utc_offset: Option i64>, - /// Certificates trusted for signing (Acrobat: Trusted Certificates). - trust: Arc, - /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). - js_off: bool, - /// Preferences ▸ Date format, when not the default (see [`dates`]). - date_format: Option, - /// Preferences ▸ Date format ▸ Language, when not following the interface language. - date_language: Option, - /// What the XFA scripts' time limits are multiplied by (`None`: 1); see - /// [`Session::with_xfa_time_scale`]. - xfa_time_scale: Option, -} - /// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; @@ -1483,1553 +1461,6 @@ fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) } -impl Session { - pub fn new() -> Self { - Self::default() - } - - /// Use a fixed clock (tests) instead of the system time. - pub fn with_clock(mut self, clock: fn() -> i64) -> Self { - self.clock = Some(clock); - self - } - - /// Multiply the time an XFA form's scripts may take by `factor` (tests): the 3 s the - /// scripts of an opening or a change share, the 5 s of a click's, the 10 s the script - /// engine has to start, and each script's own 1 s or 3 s. A test that checks what the - /// scripts do passes a large factor, so that a busy machine does not stop them. On native - /// builds, 0 stops every script before it starts; the wasm build has no wall clock, so - /// there the factor has no effect. - pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { - self.xfa_time_scale = Some(factor); - self - } - - /// A real clock for a host that has neither a clock nor a time zone of its own (a - /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so - /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in - /// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every - /// use because the zone changes with the date (daylight saving). Unlike - /// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time. - pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { - self.clock = Some(clock); - self.utc_offset = Some(utc_offset); - self - } - - /// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under - /// the test clock (which is already local), else the system's. - fn local_offset(&self) -> i64 { - match (self.utc_offset, self.clock) { - (Some(zone), _) => zone(), - (None, Some(_)) => 0, - (None, None) => local_utc_offset(), - } - } - - /// Seconds since the Unix epoch from the session clock (0 when unknown). - pub fn now_secs(&self) -> i64 { - self.now().unwrap_or(0) - } - - fn now(&self) -> Option { - if let Some(c) = self.clock { - return Some(c()); - } - #[cfg(not(target_arch = "wasm32"))] - return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64); - #[cfg(target_arch = "wasm32")] - return None; - } - - /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock - /// is taken as local time. - /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). - pub fn stamp_by_line(&self, author: &str) -> String { - let offset = self.local_offset(); - let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); - let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); - let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); - const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; - let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; - format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) - } - - pub fn today(&self) -> (i64, u32, u32) { - let offset = self.local_offset(); - self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) - } - - /// Open a document from bytes. Rendering starts lazily when pages are requested. - /// - /// `password` is tried as either the user or owner password when the file is encrypted. - /// Open saved revision `n` (1 = the oldest) of a document as a new, unsaved document named - /// " (revision n)". - pub fn open_revision(&mut self, id: DocId, n: usize) -> Result { - let doc = self.get(id).ok_or("no such document")?; - let ends = doc.revision_ends(); - let end = *n.checked_sub(1).and_then(|i| ends.get(i)).ok_or_else(|| format!("the document has {} revision(s)", ends.len()))?; - let name = format!("{} (revision {n}).pdf", doc.name.trim_end_matches(".pdf")); - let (bytes, password) = (Arc::new(doc.bytes[..end.min(doc.bytes.len())].to_vec()), doc.password.clone()); - self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) - } - - fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { - self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) - } - - /// Apply an edit. On success the previous state is undoable and the view data is refreshed. - pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { - let now = self.now(); - let today = self.today(); - let js_off = self.js_off; - let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); - let doc = self.doc_mut(id)?; - doc.edit_notices.clear(); - let name = doc.name.clone(); - let is_xfa = doc.info.xfa.is_some(); - let mut scope = scope_of(&edit); - let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); - if !js_off { - cx.xfa = doc.xfa_template.clone(); - } - cx.xfa_time_scale = xfa_time_scale; - cx.pages = doc.info.pages.len(); - cx.today = today; - let reason = doc.read_only_reason.clone().unwrap_or_default(); - let signed = doc.is_signed(); - let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; - if let Some(p) = editor.cos.permissions() { - check_permission(&edit, &p)?; - } - // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it - // in the paper's colour and draw the new text in the ink's. - let edit = match edit { - Edit::EditTextBlock { page, block, text, mut style } => { - // Only invisible text is sampled; the blocks are usually cached from opening it. - if style.cover.is_none() - && let Some(info) = doc.info.pages.get(page) - && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) - && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() - { - style.cover = Some(paper); - style.color = style.color.or(Some(ink)); - } - Edit::EditTextBlock { page, block, text, style } - } - other => other, - }; - // Nothing to bake: leave undo history and the dirty flag alone. - if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { - return Ok(()); - } - let mut next = editor.cos.clone(); - if !js_off && uses_scripts(&edit) { - cx.js = Some(js::JsRunner::new(&next, &name)); - } - cx.renamed_fields.clear(); - cx.redaction_overlays.clear(); - // `next` is a copy: if the edit fails or crashes, the document is unchanged. - guard(|| run_edit(&mut next, &edit, &mut cx)) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; - // Redaction removed what its own interpreter places under the marks. The marks were drawn - // over the page as the renderer shows it, so the renderer has the last word. - if let Edit::ApplyRedactions { pages } = &edit { - let marks: Vec = - pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); - guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; - } - // Renamed fields are said, not slipped in: a repair-log note (with the caveat that - // scripts were left alone) and a count in the undo step's label. - let renamed_note = |renamed: &[(String, String)]| { - (!renamed.is_empty()).then(|| { - let shown: Vec = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect(); - let more = renamed.len().saturating_sub(shown.len()); - let more = if more > 0 { format!(", and {more} more") } else { String::new() }; - format!( - "inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten", - renamed.len(), - if renamed.len() == 1 { "" } else { "s" }, - shown.join(", ") - ) - }) - }; - if let Some(note) = renamed_note(&cx.renamed_fields) { - next.note_repair(note); - } - // Only Insert from File renames fields; its label says so in a wording the interface - // translates (the count lives in the repair note). - let label = match (&edit, cx.renamed_fields.len()) { - (_, 0) => edit.label(), - (Edit::InsertPagesFrom { name, .. }, n) => Label::with( - if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" }, - name.as_str(), - ), - _ => edit.label(), - }; - // An XFA form's scripts answer the change: exit and validate scripts of the field, then - // every calculation (and a reset recalculates). They read the data, so it is brought up - // to date first. - if let Some(tpl) = cx.xfa.clone() { - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let datasets = &mut cx.xfa_datasets; - let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets)) - .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - cx.xfa_out.errors.extend(notes); - } - fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { - match e { - Edit::SetFieldValue { name, .. } => changed.push(name.clone()), - Edit::ResetForm { .. } => *reset = true, - Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), - _ => {} - } - } - let mut changed: Vec = Vec::new(); - let mut reset = false; - collect(&edit, &mut changed, &mut reset); - if !changed.is_empty() || reset { - let mut run = xfa::XfaRun { - page_count: cx.pages, - out: &mut cx.xfa_out, - datasets: cx.xfa_datasets, - ran_away: false, - time_scale: cx.xfa_time_scale, - }; - // Every changed field's scripts, then the calculations once (after a reset, - // only those). - let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) - .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - if let Err(e) = ran { - cx.xfa_out.errors.push(e); - } - } - } - // XFA forms keep their values in the datasets packet too, for Adobe's viewers. - let mut xfa_notes = Vec::new(); - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); - xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets)) - .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - // Rebuilding from stale data would discard accepted widget values. - if xfa_notes.is_empty() { - let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form)) - .unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - if let Some(warnings) = reflow { - xfa_notes.extend(warnings); - // Layout replaces pages and widgets, even if the page count is unchanged. - // Undo/redo must rebuild these too instead of refreshing only field values. - scope = Scope::Full; - } - } - } - // Signed documents are only ever saved incrementally: an edit that needs a full rewrite - // (applying redactions, changing security, sanitizing) would invalidate the signatures. - // So does any edit to a file rebuilt from object headers: it has no chain to append to. - let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); - if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { - return Err(EditError::SignedRewrite(edit.label().to_string())); - } - // What this edit drew with `?` (a value or comment text its font can't draw, stored as - // typed): reported, never silent (AGENTS.md §4). - let notices = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) { - guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default() - } else { - Vec::new() - }; - let previous = std::mem::replace(&mut editor.cos, next); - let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); - let previous_keys = std::mem::replace(&mut editor.keys, keys); - editor.undo.push((label, previous, previous_keys, scope)); - if editor.undo.len() > MAX_UNDO { - editor.undo.remove(0); - } - editor.redo.clear(); - Self::adopt_keys(doc); - let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) - .unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - // Roll back: the edit produced something we cannot display. - if let Some(ed) = doc.editor.as_mut() - && let Some((_, prev, keys, _)) = ed.undo.pop() - { - ed.cos = prev; - ed.keys = keys; - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.dirty = true; - doc.generation += 1; - doc.edit_notices = notices; - if !cx.xfa_removed.is_empty() { - let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); - doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); - note_warnings(&mut doc.xfa_warnings, &removed); - note_warnings(&mut doc.info.warnings, &removed); - } - note_warnings(&mut doc.xfa_warnings, &xfa_notes); - let notes = doc.xfa_warnings.clone(); - note_warnings(&mut doc.info.warnings, ¬es); - if let Some(js) = cx.js { - doc.js_output.append(js.output); - } - doc.js_output.append(cx.xfa_out); - if cx.xfa_ran_away { - doc.xfa_template = None; - doc.xfa_scripts_off = true; - doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } - Ok(()) - } - - pub fn undo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; - let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; - let current = std::mem::replace(&mut editor.cos, prev); - let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.redo.push((label.clone(), current, current_keys, scope)); - Self::adopt_keys(doc); - let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - if let Some(ed) = doc.editor.as_mut() - && let Some((label, current, current_keys, scope)) = ed.redo.pop() - { - let prev = std::mem::replace(&mut ed.cos, current); - let keys = std::mem::replace(&mut ed.keys, current_keys); - ed.undo.push((label, prev, keys, scope)); - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.edit_notices.clear(); - Self::sync_dirty(doc); - doc.generation += 1; - Ok(label) - } - - pub fn redo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; - let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; - let current = std::mem::replace(&mut editor.cos, next); - let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.undo.push((label.clone(), current, current_keys, scope)); - Self::adopt_keys(doc); - let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - if let Some(ed) = doc.editor.as_mut() - && let Some((label, current, current_keys, scope)) = ed.undo.pop() - { - let next = std::mem::replace(&mut ed.cos, current); - let keys = std::mem::replace(&mut ed.keys, current_keys); - ed.redo.push((label, next, keys, scope)); - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.edit_notices.clear(); - Self::sync_dirty(doc); - doc.generation += 1; - Ok(label) - } - - /// Unsaved when the working graph is not the last opened or saved file. Undo back to that - /// file is clean; undo past a later save is not, because the restored snapshot's own base - /// is an earlier revision. - fn sync_dirty(doc: &mut Document) { - doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); - } - - /// Use the current state's password for viewing (protection edits change it). - fn adopt_keys(doc: &mut Document) { - if let Some(e) = doc.editor.as_ref() { - doc.password = e.keys.render.clone(); - doc.config.password = e.keys.render.as_deref().map(Arc::from); - } - } - - /// Rebuild the working bytes and the view data that `scope` may have changed. Metadata edits - /// keep the renderer; other scopes also replace it. - /// Comment and form edits skip the full re-inspection (seconds on very large files): the - /// comment list and field values are re-read from the object graph instead. - fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> { - if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) { - return Self::refresh(doc); - } - if scope == Scope::Content { - return Self::refresh_content(doc, None); - } - if scope == Scope::Links { - return Self::refresh_links(doc); - } - // Metadata edits keep the renderer, which holds the working file anyway. - if scope != Scope::Metadata { - Self::release_working(doc); - } - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - // Comment edits, and form edits (a script may change annotation properties), may change - // annotation dictionaries, invalidating cached appearances. - if matches!(scope, Scope::Comments | Scope::Form) { - editor.appearances.clear(); - } - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - if scope == Scope::Metadata { - for (field, value) in info_fields(&mut doc.info, &editor.cos) { - *field = value; - } - doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); - doc.info.file_size = bytes.len(); - #[cfg(test)] - check_refresh_panic(); - if !doc.signatures.is_empty() { - let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - doc.signatures = Arc::new(signatures); - } - doc.bytes = bytes; - return Ok(()); - } - let mut form = pdfcraft_forms::fields(&editor.cos); - xfa::mark_script_buttons(&editor.cos, &mut form); - let form = Arc::new(form); - match scope { - Scope::Metadata => return Ok(()), - Scope::Comments => { - doc.info.annotations = comment_list(&editor.cos); - // Link indexes are positions in /Annots, so comment insertion/deletion can move them. - doc.links = OnceLock::new(); - } - Scope::Form => { - // PERF: keystroke path, index once. A linear find per field is O(F^2); - // forms reach 10k fields. - let mut by_name = std::collections::HashMap::with_capacity(form.len()); - for x in form.iter() { - // First match wins (do not simplify to collect: it keeps the last). - by_name.entry(x.name.as_str()).or_insert(x); - } - for f in &mut doc.info.fields { - if let Some(ff) = by_name.get(f.name.as_str()) { - f.value = match ff.kind { - pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => { - Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) - } - _ if ff.value.is_empty() => None, - _ => Some(ff.value.join(", ")), - }; - } - } - } - Scope::Links => return Self::refresh_links(doc), - // Handled above; kept as a full refresh so the match stays total. - Scope::Full => return Self::refresh(doc), - // Handled above (content-only refresh); the arm keeps the match total. - Scope::Content => return Self::refresh_content(doc, None), - } - doc.info.file_size = bytes.len(); - doc.form = form; - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - Ok(()) - } - - /// Which derived lists a content-only edit can change: added content (`None`, from - /// undo/redo, re-reads both: always correct, rarely paid). - fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) { - if let Some(Edit::Batch { edits, .. }) = edit { - // A batch of content edits (from a script, say) changes what its parts change. - return edits.iter().fold((false, false), |(added, marks), part| { - let (a, m) = Self::content_refresh_lists(Some(part)); - (added || a, marks || m) - }); - } - let touches_added = edit.is_none_or(|e| { - matches!( - e, - Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) - }); - let touches_marks = edit.is_none_or(|e| { - matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) - }); - (touches_added, touches_marks) - } - - /// Rebuild working bytes, display bytes and renderer after a content-only edit: page - /// content streams, resources and added content/marks. The page tree, annotations, form - /// fields, links, outline and fonts are unchanged, so the full re-inspection (seconds on - /// very large files) is skipped. `edit` selects which derived lists are re-read (`None`, - /// from undo/redo, re-reads both: always correct, rarely paid). The cached display - /// appearances are kept: content edits leave every annotation dictionary alone. - fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> { - Self::release_working(doc); - sync_xfa_state(doc); - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - // The edit must still leave a file the viewer can open; anything else rolls back above. - pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?; - let (touches_added, touches_marks) = Self::content_refresh_lists(edit); - if touches_added || touches_marks { - doc.marker_scan = OnceLock::new(); - } - doc.info.file_size = bytes.len(); - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - doc.fonts_stale = true; - Ok(()) - } - - /// Let go of the working file before the next one is written (gap 13). The working file is - /// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the - /// working bytes, the display bytes and the renderer) while writing the new one held three - /// copies at once; this keeps two. Until the refresh finishes, the working and display bytes - /// are the original's (already held by the editor, so free) and the renderer draws nothing. - /// A failed edit rolls back and refreshes again, which replaces all three; where a refresh - /// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes - /// from the editor, never from these. - fn release_working(doc: &mut Document) { - let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return }; - // Dropping the pool stops its workers; each lets go of the file when it exits. - doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone()); - doc.display = original.clone(); - doc.bytes = original; - #[cfg(test)] - check_refresh_panic(); - } - - /// After a refresh failed: draw the display bytes it left (the original, once released), - /// so the page still shows a document. - fn show_released(doc: &mut Document) { - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - - /// Rebuild working bytes, inspection and renderer from the current edit state. - fn refresh(doc: &mut Document) -> Result<(), EditError> { - Self::refresh_with_indexes(doc, true, true) - } - - /// Reinspect a link edit while preserving the page-content marker scan cache. - fn refresh_links(doc: &mut Document) -> Result<(), EditError> { - Self::refresh_with_indexes(doc, false, true) - } - - fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { - // A full refresh may follow structural or annotation changes: cached appearances - // may be stale, so they are rebuilt below. - if let Some(editor) = doc.editor.as_mut() { - editor.appearances.clear(); - } - Self::release_working(doc); - sync_xfa_state(doc); - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - let info = inspect(bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - // Keep the user's layer choices where the layers still exist. - let mut info = info; - for l in &mut info.layers { - if let Some(old) = doc.info.layers.iter().find(|o| o.id == l.id) { - l.visible = old.visible; - } - } - note_warnings(&mut info.warnings, &doc.xfa_warnings); - // As in `build_document`: the inspection reads only `/Info`, so fields stated only in XMP - // are filled in again after every full refresh. - for (field, value) in info_fields(&mut info, &editor.cos) { - if field.is_none() { - *field = value; - } - } - doc.info = info; - doc.fonts_stale = false; - let mut form = pdfcraft_forms::fields(&editor.cos); - xfa::mark_script_buttons(&editor.cos, &mut form); - doc.form = Arc::new(form); - if invalidate_markers { - doc.marker_scan = OnceLock::new(); - } - if invalidate_links { - doc.links = OnceLock::new(); - } - let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - Ok(()) - } - - /// The bytes to write for Save: an incremental update of the file as opened/last saved, - /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully. - /// - /// A file whose cross-reference data had to be rebuilt is written in full even when nothing - /// was edited: its bytes as opened are the damaged file, and this save is how the repair the - /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would - /// break its signatures, so with no edits it keeps its bytes, damage and all. - pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; - let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); - if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { - return Ok(editor.saved.clone()); - } - // What the user removed again before saving (a cleared redaction mark) stays out of the file. - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), drop_unlinked: true, ..SaveOptions::default() }; - // A restored snapshot can be based on an earlier revision. Append to the last saved - // file so that revision stays in the chain. A full rewrite (redaction, new encryption, - // a reconstructed file) still writes the current graph on its own. - let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); - let cos = writable(&editor.cos)?; - guard(|| { - if on_saved || rewrite { - write_incremental(&cos, &opts) - } else { - let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; - base.overlay_changes_from(&cos)?; - write_incremental(&base, &opts) - } - }) - .map_err(EditError::Write)? - .map(Arc::new) - .map_err(|e| EditError::Write(e.to_string())) - } - - /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). - pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - if doc.is_signed() { - return Err(EditError::Signed); - } - let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - let cos = writable(&editor.cos)?; - guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) - } - - /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this - /// document before they are written (#1698): [`Self::mark_saved`] only finds out after the - /// file has been replaced. Same error as `mark_saved`. - pub fn check_save(&self, id: DocId, bytes: &Arc>) -> Result<(), EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; - pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string())) - } - - /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file - /// so the next save appends only newer edits. Undo history is kept. - pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - if let Some(editor) = doc.editor.as_mut() { - // An encrypted file needs the password it is protected with now (the owner - // password when known, so saving never downgrades this session's rights). - let keys = editor.keys.clone(); - // The saved file carries no Password-field values (#3943, #3944); the open document keeps them. - let typed = pdfcraft_forms::password_values(&editor.cos); - editor.cos = - pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; - if !typed.is_empty() && doc.info.xfa.is_some() { - let mut datasets = None; - let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets); - } - editor.saved = bytes; - editor.saved_keys = keys; - editor.unsaved_file = false; - } - if let Some(p) = path { - doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); - doc.path = Some(p); - } - doc.dirty = false; - doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) - } - - /// Enter the permissions (owner) password of a document opened with its user password, - /// lifting its restrictions for this session (Acrobat asks for it before changing security). - /// Not an edit: nothing is marked unsaved and there is no undo step. Undo and redo states - /// are unlocked as well, and saving keeps the rights. Unencrypted documents and documents - /// opened by the owner need nothing. - pub fn unlock(&mut self, id: DocId, password: &str) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - if editor.cos.permissions().is_none_or(|p| p.owner) { - return Ok(()); - } - editor.cos.unlock(password).map_err(|e| match e { - pdfcraft_cos::CosError::WrongPassword => EditError::WrongPassword, - other => EditError::Protection(other.to_string()), - })?; - // Saving a state that keeps the security it was opened with reopens it with the same - // passwords, so it reopens as owner. - let reopen_as_owner = |cos: &pdfcraft_cos::Document, keys: &mut Keys| { - if !cos.encryption_changed() { - keys.reopen = Some(password.to_owned()); - } - }; - reopen_as_owner(&editor.cos, &mut editor.keys); - // Other states with the same restricted access are unlocked too; states opened with - // other passwords (protection changed and saved since) stay as they are. - for (_, cos, keys, _) in editor.undo.iter_mut().chain(editor.redo.iter_mut()) { - if cos.permissions().is_some_and(|p| !p.owner) && cos.unlock(password).is_ok() { - reopen_as_owner(cos, keys); - } - } - Ok(()) - } - - /// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is - /// cleared, as in Acrobat. - pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - let base = editor.saved.clone(); - // The saved file opens with the passwords it was saved with. The current snapshot may - // be an older revision (undo past that save), so its own base is not the saved file. - let mut candidates = vec![editor.saved_keys.clone(), editor.keys.clone()]; - candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone())); - candidates.push(Keys::default()); - let (cos, keys) = candidates - .into_iter() - .find_map(|k| pdfcraft_cos::Document::open_with_password(base.clone(), k.reopen.as_deref()).ok().map(|c| (c, k))) - .ok_or_else(|| EditError::Reopen("the saved file can't be opened".into()))?; - editor.cos = cos; - editor.keys = keys; - editor.undo.clear(); - editor.redo.clear(); - Self::adopt_keys(doc); - Self::sync_dirty(doc); - doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) - } - - /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open - /// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA - /// layout and scripts, signatures, repairs), but the document keeps its id, name and path, - /// so its tab and view stay with it. Undo history goes, as with Revert. The password it was - /// opened with is tried; on any failure the document is left as it was. - pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { - let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; - let doc = &self.docs[old]; - let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); - let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); - let new_id = self.open(name, path, bytes, password.as_deref())?; - let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?; - let mut doc = self.docs.remove(at); - doc.id = id; - // How the document is shown is the reader's, not the file's: reading colours and hidden - // comments stay as they were (#3443). `open` built the renderer with the defaults. - if let Some(old) = self.docs.get(old) - && (doc.config.reading_colors != old.config.reading_colors || doc.config.hide_comments != old.config.hide_comments) - { - doc.config.reading_colors = old.config.reading_colors; - doc.config.hide_comments = old.config.hide_comments; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - // Caches keyed by the edit generation (edit lines, images, measurements) must see a new - // one; there is nothing new to autosave. - doc.generation = generation + 1; - doc.snapshot_generation = doc.generation; - // `open` pushed the new version last, so the old one is still at `old`. - *self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc; - Ok(()) - } - - /// The page count of another PDF (Replace Pages, Insert Pages dialogs). - pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { - Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?) - } - - /// A new blank document (Create ▸ Blank page). - pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { - self.write_new(&pdfcraft_create::blank(width, height, pages)?) - } - - /// A new document with one page per image (PNG, JPEG). - pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images(images)?) - } - - /// Create image pages at embedded resolution or a fixed dpi, without resampling. - pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) - } - - /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is - /// refused, naming the line and the character (never set as `?`). - pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) - } - - /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, - /// compare reports), which can quote any language: characters Helvetica can't draw are written - /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. - fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { - let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; - self.write_new(&doc) - } - - /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked - /// (it must open and allow copying pages) and returned as it is. - pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { - let Some(kind) = source_kind(name, bytes) else { - return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); - }; - let title = name.rsplit_once('.').map_or(name, |(s, _)| s); - // Image decoders read untrusted bytes: a panic in one must not take the app down. - let created = guard(|| match kind { - SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), - SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), - SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), - }) - .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; - Ok((kind, created?)) - } - - /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as - /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf - /// without a `%PDF-` header, which the reader may still open. - pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { - let pdf = match source_kind(name, bytes) { - Some(SourceKind::Pdf) => true, - Some(SourceKind::Image | SourceKind::Text) => false, - None => name.to_ascii_lowercase().ends_with(".pdf"), - }; - if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } - } - - /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium - /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects - /// packed into compressed object streams. Returns the bytes and how many objects were - /// merged. The open document is not changed (Acrobat saves the reduced copy as a new file). - pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { - let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; - Ok((bytes, report.merged)) - } - - /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove - /// Hidden Information's `discard` categories (user data). A full rewrite: signed documents - /// are refused. The open document is not changed. This is [`Self::optimize_job`] run in - /// place, without progress. - pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc>, OptimizeReport), EditError> { - self.optimize_job(id, settings, discard)?.run(|_| true) - } - - /// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text - /// are form data only. - pub fn export_data(&self, id: DocId, format: DataFormat, comments: bool, fields: bool) -> Result, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => { - pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? - } - }; - // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. - let xfa = doc.info.xfa.is_some(); - if format == DataFormat::Xdp && !xfa { - return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); - } - guard(|| match format { - DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), - DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()), - DataFormat::Xml | DataFormat::Xdp if xfa => { - pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) - } - other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), - }) - .map_err(EditError::Write)? - .map_err(EditError::Write) - } - - /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). - pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - if !doc.allows_printing() { - return Err(EditError::NotPermitted("printing")); - } - // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages - // always go as pictures, at no more than the low resolution. - let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { - dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), - grayscale: settings.raster.is_some_and(|r| r.grayscale), - }); - // Print as image: the pages used become pictures first, then the picture document is - // imposed in their place (its page k is the k-th distinct source page used). - if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) { - let mut distinct: Vec = Vec::new(); - for &p in &settings.pages { - if !distinct.contains(&p) { - distinct.push(p); - } - } - let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?; - let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; - let remapped = print::Settings { - pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), - content: print::Content::Document, - raster: None, - ..settings.clone() - }; - return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); - } - let mut cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => { - pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? - } - }; - // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer - // action) has changed one, the print copy's default configuration says which are on, so a - // hidden layer stays off on paper too; untouched, the document's own configuration prints. - if !doc.config.layers.is_empty() { - set_default_layer_state(&mut cos, &doc.info.layers); - } - pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) - } - - /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); - /// only `pages` are read, the others are `false`. - pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { - let Some(doc) = self.get(id) else { return Vec::new() }; - let cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { - Ok(c) => c, - Err(_) => return Vec::new(), - }, - }; - (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() - } - - /// Combine whole files, in order, into new PDF bytes (one bookmark per file). - pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { - let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; - let named: Vec<(&str, &pdfcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect(); - let out = pdfcraft_organize::combine(&named)?; - self.write_new(&out) - } - - /// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages). - pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result>, EditError> { - self.combine_unlocked(sources, &[]) - } - - /// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as - /// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see - /// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a - /// time: a source is opened, its pages are copied into the result, and its bytes are released - /// before the next item is read. So the number of sources is not limited, and one source's - /// document is held at a time. An item's error stops the combine and is returned as it is. - /// `Ok(None)` when there are no sources. - pub fn combine_each(&self, sources: I) -> Result>>, E> - where - I: IntoIterator>, - E: From, - { - let mut combiner = pdfcraft_organize::Combiner::new(); - let mut added = false; - for source in sources { - let (title, bytes, range) = source?; - let doc = open_source(&title, &bytes)?; - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - let pages = match range { - Some(r) => { - let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{title}: {e}")))?; - Some(p) - } - None => None, - }; - combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?; - added = true; - } - if !added { - return Ok(None); - } - let out = combiner.finish().map_err(EditError::from)?; - Ok(Some(self.write_new(&out)?)) - } - - /// [`Self::combine_ranges`] with the password each encrypted source is opened with, by - /// position (missing or `None`: no password). The result is not encrypted. - pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result>, EditError> { - Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) - } - - /// The pre-flight over byte sources: what combining them, in order, would rename in each one - /// (whole files; page ranges are the combine's to apply). A source that cannot be opened - /// previews as nothing — its row already says why it cannot be combined. - pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec> { - let docs: Vec> = - sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect(); - let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(Option::as_ref).collect(); - let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter(); - // One preview per source, in step with it: a file that cannot be opened has none. - docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect() - } - - /// [`Self::combine_unlocked`] reporting what the conflict resolution renamed. - pub fn combine_unlocked_reporting( - &self, - sources: &[CombineSource], - passwords: &[Option<&str>], - field_conflicts: pdfcraft_organize::FieldConflicts, - ) -> Result { - let docs = sources - .iter() - .enumerate() - .map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten())) - .collect::, _>>()?; - let mut pages = Vec::with_capacity(docs.len()); - for ((name, _, range), d) in sources.iter().zip(&docs) { - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - pages.push(match range { - Some(r) => { - let n = pdfcraft_organize::page_count(d)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{name}: {e}")))?; - Some(p) - } - None => None, - }); - } - let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> = - sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect(); - let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?; - let bytes = self.write_new(&out.doc)?; - Ok(Combined { bytes, renamed_fields: out.renamed_fields }) - } - - /// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one - /// file split into several runs placed apart: each file is opened and copied once, so it keeps - /// one bookmark, its links between its own pages, its fields and its attachments whole (see - /// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes - /// and password; a source without a key is a file of its own. - pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result>, EditError> { - Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) - } - - /// [`Self::combine_grouped`] reporting what the conflict resolution renamed. - pub fn combine_grouped_reporting( - &self, - sources: &[CombineSource], - groups: &[u64], - passwords: &[Option<&str>], - field_conflicts: pdfcraft_organize::FieldConflicts, - ) -> Result { - let (mut keys, mut docs, mut file): (Vec>, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new()); - for (i, (name, bytes, _)) in sources.iter().enumerate() { - let key = groups.get(i).copied(); - let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k))); - let at = match known { - Some(at) => at, - None => { - docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?); - keys.push(key); - docs.len() - 1 - } - }; - file.push(at); - } - let mut pages = Vec::with_capacity(sources.len()); - for ((name, _, range), at) in sources.iter().zip(&file) { - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; - pages.push(match range { - Some(r) => { - let n = pdfcraft_organize::page_count(d)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{name}: {e}")))?; - Some(p) - } - None => None, - }); - } - let mut runs: Vec> = Vec::with_capacity(sources.len()); - for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) { - let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; - runs.push((*at, name.as_str(), d, p.as_deref())); - } - let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?; - let bytes = self.write_new(&out.doc)?; - Ok(Combined { bytes, renamed_fields: out.renamed_fields }) - } - - /// New PDF bytes containing copies of `pages` of the document (Extract Pages). - pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("extracting pages")); - } - let out = pdfcraft_organize::extract_pages(src, pages)?; - self.write_new(&out) - } - - /// Split the document into several new PDFs. - pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("splitting the document")); - } - let n = pdfcraft_organize::page_count(src)?; - pdfcraft_organize::split_ranges(n, by) - .into_iter() - .map(|r| { - let doc = pdfcraft_organize::extract_pages(src, &r.clone().collect::>())?; - Ok((r.start + 1, r.end, self.write_new(&doc)?)) - }) - .collect() - } - - /// Split into parts of at most `max_bytes` each (Acrobat's "File size"): pages are taken in - /// order while their size (as single-page files, an over-estimate because shared fonts and - /// images count once per part) fits; a page larger than the limit is a part of its own. - pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("splitting the document")); - } - let n = pdfcraft_organize::page_count(src)?; - let mut cuts = Vec::new(); - let mut used = 0usize; - for p in 0..n { - let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); - if used > 0 && used + size > max_bytes { - cuts.push(p); - used = 0; - } - used += size; - } - self.split(id, &pdfcraft_organize::SplitBy::Before(cuts)) - } - - /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle - /// per line in user space, URI). Text that already has a link over it is skipped. - pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { - let Some(doc) = self.get(id) else { return Vec::new() }; - let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); - let mut out = Vec::new(); - for page in 0..doc.info.pages.len() { - let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); - let Some(text) = res.text else { continue }; - let found: std::cell::RefCell, String)>> = Default::default(); - let hits = text.find_with(|chars| { - let urls = pdfcraft_annot::links::find_urls(chars); - let ranges = urls.iter().map(|u| u.0.clone()).collect(); - *found.borrow_mut() = urls; - ranges - }); - for (glyphs, (_, uri)) in hits.into_iter().zip(found.into_inner()) { - let rects: Vec<[f64; 4]> = text - .line_rects(glyphs) - .into_iter() - .map(|v| { - let q = doc.info.pages[page].view_rect_to_quad(v); - let xs = [q[0], q[2], q[4], q[6]]; - let ys = [q[1], q[3], q[5], q[7]]; - [ - xs.iter().copied().fold(f64::MAX, f64::min), - ys.iter().copied().fold(f64::MAX, f64::min), - xs.iter().copied().fold(f64::MIN, f64::max), - ys.iter().copied().fold(f64::MIN, f64::max), - ] - }) - .filter(|r| { - !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) - }) - .collect(); - if !rects.is_empty() { - out.push((page, rects, uri)); - } - } - } - out - } - - /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. - /// The document's own structure is read for it, so every bookmark can be reached by paging. - pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { - let doc = self.get(id)?; - if let Some(editor) = doc.editor.as_ref() { - let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit); - let rows = page - .bookmarks - .into_iter() - .map(|b| { - let view = b.view.map_or_else(Default::default, |(kind, numbers)| { - let numbers: Vec> = numbers.iter().map(|n| n.map(|n| n as f32)).collect(); - pdfcraft_render::DestView::from_operands(Some(&kind), &numbers) - }); - BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold } - }) - .collect(); - return Some(BookmarkPage { rows, next: page.next, truncated: false }); - } - let all = outline_rows(&doc.info.outline); - let end = offset.saturating_add(limit); - let rows = all.iter().skip(offset).take(limit).cloned().collect(); - Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) - } - - /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). - pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { - let Some(doc) = self.get(id) else { return Vec::new() }; - let top: Vec<(Option, String)> = match doc.editor.as_ref() { - Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(), - None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(), - }; - let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect(); - out.sort_by_key(|x| x.0); - out.dedup_by_key(|x| x.0); - out - } - - /// Open freshly created bytes (combine / extract) as a new, unsaved document. - pub fn open_new(&mut self, name: impl Into, bytes: Arc>) -> Result { - let id = self.open(name, None, bytes, None)?; - if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { - d.dirty = true; - if let Some(e) = d.editor.as_mut() { - e.unsaved_file = true; - } - // Unsaved work with no edit yet still needs its first recovery snapshot. - d.generation += 1; - } - Ok(id) - } - - fn cos(&self, id: DocId) -> Result<&pdfcraft_cos::Document, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default())) - } - - /// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone - /// writes it (#1698, #2166): it reopens, and shows the pages it was built from. - fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { - let bytes = self.write_unchecked(doc)?; - let expected = pdfcraft_organize::page_count(doc)?; - pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?; - Ok(bytes) - } - - /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. - /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and - /// Split copy the fields of the pages they take. - fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - let doc = writable(doc)?; - write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) - } - - /// Whether a changed document still needs a recovery snapshot. This does not consume it. - pub fn has_pending_autosave(&self) -> bool { - self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation) - } - - /// Documents with unsaved changes made since the last call: their current working file, - /// for crash recovery. Encrypted documents stay encrypted in the snapshot. - pub fn autosave_snapshots(&mut self) -> Vec { - let mut out = Vec::new(); - for d in &mut self.docs { - if d.dirty && d.generation != d.snapshot_generation { - // The working bytes hold typed Password-field values (#3926, #3944): a recovery file - // is written from a copy without them, and none is written if that copy fails. - let bytes = - match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) { - Some(e) => match writable(&e.cos) - .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) - { - Ok(b) => Arc::new(b), - Err(_) => continue, - }, - None => d.bytes.clone(), - }; - d.snapshot_generation = d.generation; - out.push(RecoverySnapshot { - doc: d.id, - name: d.name.clone(), - path: d.path.clone(), - bytes, - encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), - }); - } - } - out - } - - /// Mark a document opened from a recovery file: it has unsaved changes and belongs at - /// `path` (where Save writes), as when the session ended. - pub fn mark_recovered(&mut self, id: DocId, path: Option) { - if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { - d.path = path; - d.dirty = true; - if let Some(e) = d.editor.as_mut() { - e.unsaved_file = true; - } - d.generation += 1; - d.snapshot_generation = d.generation; // its bytes are already in the recovery store - } - } - - /// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed; - /// callers must drop cached rasters and text for the document. - pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - let Some(l) = doc.info.layers.get_mut(layer) else { return false }; - if l.visible == visible { - return false; - } - l.visible = visible; - use_layer_choices(doc); - true - } - - /// Run a set-layer-visibility action (`SetOCGState`): apply `changes` in order, naming each - /// layer by its optional content group; groups that aren't layers are skipped. With - /// `preserve_rb`, a layer turned on turns off the other layers of its radio-button groups. - /// Returns `true` if any layer changed; callers must drop cached rasters and text for the - /// document, as for [`Self::set_layer_visible`]. - pub fn set_layer_state(&mut self, id: DocId, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if !apply_layer_state(&mut doc.info.layers, &doc.info.layer_groups, changes, preserve_rb) { - return false; - } - use_layer_choices(doc); - true - } - - /// Show each layer as the file opens (Acrobat's Layers ▸ Reset to Initial Visibility). Returns - /// `true` if any layer changed; callers must drop cached rasters and text for the document, - /// as for [`Self::set_layer_visible`]. - pub fn reset_layers(&mut self, id: DocId) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - let mut changed = false; - for l in &mut doc.info.layers { - changed |= l.visible != l.initial; - l.visible = l.initial; - } - if changed { - use_layer_choices(doc); - } - changed - } - - /// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form - /// fields and links still draw. Returns whether anything changed. - pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if doc.config.hide_comments == hide { - return false; - } - doc.config.hide_comments = hide; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.generation += 1; - true - } - - /// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether - /// anything changed; callers must drop cached rasters and text for the document. - pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if doc.config.reading_colors == colors { - return false; - } - doc.config.reading_colors = colors; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.generation += 1; - true - } - - /// The document's reading-colours setting; `None` when no such document is open. - pub fn reading_colors(&self, id: DocId) -> Option { - Some(self.get(id)?.config.reading_colors) - } - - /// How the document's pages are drawn for viewing: its password and view settings (layer - /// visibility, hidden comments, reading colours). `None` when no such document is open. - pub fn render_config(&self, id: DocId) -> Option { - Some(self.get(id)?.config.clone()) - } - - /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment - /// with its number, author, type, date, text and replies, grouped by page. - pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let text = comment_summary(&doc.name, &doc.info.annotations, sort); - self.create_report(&format!("Summary of Comments on {}", doc.name), &text) - } - - /// The certificates trusted for signing. - pub fn trusted_certificates(&self) -> &[pdfcraft_sign::Certificate] { - &self.trust.certs - } - - /// Replace the trusted certificates, revalidate every open document's signatures, - /// and rebuild its display copy and renderer to match (working file untouched, - /// generation/dirty unchanged: trust alone is not an edit). - pub fn set_trusted_certificates(&mut self, certs: Vec) { - self.trust = Arc::new(TrustStore { certs }); - for doc in &mut self.docs { - doc.trust = self.trust.clone(); - let Some(editor) = doc.editor.as_mut() else { continue }; - // Keep verdict refresh and renderer refresh together. Viewer caches are - // invalidated by the UI trust path; trust alone changes no document artwork. - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &doc.bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - } - - /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). - pub fn signing_date(&self) -> String { - let offset = self.local_offset(); - let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset); - let stamp = local.trim_end_matches('Z'); - if offset == 0 { - return format!("{stamp}Z"); - } - let (sign, m) = if offset < 0 { ('-', -offset / 60) } else { ('+', offset / 60) }; - format!("{stamp}{sign}{:02}'{:02}'", m / 60, m % 60) - } - - /// Sign the document's current state with `id` (Use a certificate ▸ Digitally sign). Returns - /// the signed file; the caller saves it and then calls [`Session::mark_signed`]. An empty - /// `opts.date` takes the session clock. - pub fn sign(&self, doc: DocId, id: &pdfcraft_sign::DigitalId, mut opts: SignOptions) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - // Encrypted documents are signed under their permissions (the signer checks them). - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - if opts.date.is_empty() { - opts.date = self.signing_date(); - } - // A typed Password-field value is not signed into the file (#3926), as it isn't saved. - let cos = writable(&editor.cos)?; - Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) - } - - /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by - /// `tsa`. The transport lives with the caller; the engine never opens a socket. - pub fn sign_with_timestamp( - &self, - doc: DocId, - id: &pdfcraft_sign::DigitalId, - mut opts: SignOptions, - tsa: &dyn pdfcraft_sign::TimestampAuthority, - ) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - if opts.date.is_empty() { - opts.date = self.signing_date(); - } - let cos = writable(&editor.cos)?; - let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?); - // Checked like a save before anyone writes it (#2166). - self.check_save(doc, &signed)?; - Ok(signed) - } - - /// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's - /// current state. An empty `date` takes the session clock; the transport is the caller's. - pub fn timestamp_document(&self, doc: DocId, tsa: &dyn pdfcraft_sign::TimestampAuthority, date: String) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - let date = if date.is_empty() { self.signing_date() } else { date }; - let cos = writable(&editor.cos)?; - let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); - self.check_save(doc, &stamped)?; - Ok(stamped) - } - - /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature - /// (PAdES B-LT): an incremental update that never rewrites signed bytes. - pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - let cos = writable(&editor.cos)?; - let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); - self.check_save(doc, &embedded)?; - Ok(embedded) - } - - /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], - /// and the edit history before signing is dropped (signing can't be undone). - pub fn mark_signed(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { - self.mark_saved(id, bytes, path)?; - let doc = self.doc_mut(id)?; - if let Some(e) = doc.editor.as_mut() { - e.undo.clear(); - e.redo.clear(); - } - Ok(()) - } - - pub fn close(&mut self, id: DocId) { - self.docs.retain(|d| d.id != id); - } - - pub fn get(&self, id: DocId) -> Option<&Document> { - self.docs.iter().find(|d| d.id == id) - } - - /// Read document `id`'s font list again if content edits since the last full inspection may - /// have changed it (Document Properties ▸ Fonts, `doc_info`). A no-op otherwise. - pub fn refresh_fonts(&mut self, id: DocId) { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id && d.fonts_stale) else { return }; - if let Ok(info) = pdfcraft_render::inspect(doc.bytes.clone(), doc.password.as_deref()) { - doc.info.fonts = info.fonts; - } - doc.fonts_stale = false; - } - - pub fn docs(&self) -> &[Document] { - &self.docs - } -} - /// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` /// (0-based) of a PDF. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs new file mode 100644 index 0000000000..c3e34d90bd --- /dev/null +++ b/crates/engine/src/session.rs @@ -0,0 +1,1590 @@ +//! The session: the open documents and the operations on them. + +use std::sync::{Arc, OnceLock}; + +use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; +use pdfcraft_render::{LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; + +#[cfg(test)] +use crate::check_refresh_panic; +use crate::edit::{EditCtx, check_permission, run_edit}; +use crate::history::{Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts}; +use crate::{ + BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hidden, ImageResolution, + LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, SummarySort, TrustStore, + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, comment_list, comment_summary, decode_text, display_bytes, guard, info_fields, js, + local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_raster, render_threads, + scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, + writable, xfa, xfa_sync_datasets, +}; + +#[derive(Default)] +pub struct Session { + pub(crate) docs: Vec, + pub(crate) next_id: u64, + /// Seconds since the Unix epoch, injected so saves are deterministic in tests. + clock: Option i64>, + /// Seconds to add to UTC for local time, read from whoever knows the zone (a browser, say). + /// Separate from `clock` on purpose: `clock` alone is the test shortcut that takes the + /// injected time AS local time and zeroes the zone, so injecting only it leaves either the + /// local dates or the UTC ones wrong by the zone's offset. + utc_offset: Option i64>, + /// Certificates trusted for signing (Acrobat: Trusted Certificates). + pub(crate) trust: Arc, + /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). + pub(crate) js_off: bool, + /// Preferences ▸ Date format, when not the default (see [`dates`]). + pub(crate) date_format: Option, + /// Preferences ▸ Date format ▸ Language, when not following the interface language. + pub(crate) date_language: Option, + /// What the XFA scripts' time limits are multiplied by (`None`: 1); see + /// [`Session::with_xfa_time_scale`]. + pub(crate) xfa_time_scale: Option, +} + +impl Session { + pub fn new() -> Self { + Self::default() + } + + /// Use a fixed clock (tests) instead of the system time. + pub fn with_clock(mut self, clock: fn() -> i64) -> Self { + self.clock = Some(clock); + self + } + + /// Multiply the time an XFA form's scripts may take by `factor` (tests): the 3 s the + /// scripts of an opening or a change share, the 5 s of a click's, the 10 s the script + /// engine has to start, and each script's own 1 s or 3 s. A test that checks what the + /// scripts do passes a large factor, so that a busy machine does not stop them. On native + /// builds, 0 stops every script before it starts; the wasm build has no wall clock, so + /// there the factor has no effect. + pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { + self.xfa_time_scale = Some(factor); + self + } + + /// A real clock for a host that has neither a clock nor a time zone of its own (a + /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so + /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in + /// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every + /// use because the zone changes with the date (daylight saving). Unlike + /// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time. + pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { + self.clock = Some(clock); + self.utc_offset = Some(utc_offset); + self + } + + /// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under + /// the test clock (which is already local), else the system's. + fn local_offset(&self) -> i64 { + match (self.utc_offset, self.clock) { + (Some(zone), _) => zone(), + (None, Some(_)) => 0, + (None, None) => local_utc_offset(), + } + } + + /// Seconds since the Unix epoch from the session clock (0 when unknown). + pub fn now_secs(&self) -> i64 { + self.now().unwrap_or(0) + } + + pub(crate) fn now(&self) -> Option { + if let Some(c) = self.clock { + return Some(c()); + } + #[cfg(not(target_arch = "wasm32"))] + return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64); + #[cfg(target_arch = "wasm32")] + return None; + } + + /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock + /// is taken as local time. + /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). + pub fn stamp_by_line(&self, author: &str) -> String { + let offset = self.local_offset(); + let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); + let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); + let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); + const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; + let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; + format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) + } + + pub fn today(&self) -> (i64, u32, u32) { + let offset = self.local_offset(); + self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) + } + + /// Open a document from bytes. Rendering starts lazily when pages are requested. + /// + /// `password` is tried as either the user or owner password when the file is encrypted. + /// Open saved revision `n` (1 = the oldest) of a document as a new, unsaved document named + /// " (revision n)". + pub fn open_revision(&mut self, id: DocId, n: usize) -> Result { + let doc = self.get(id).ok_or("no such document")?; + let ends = doc.revision_ends(); + let end = *n.checked_sub(1).and_then(|i| ends.get(i)).ok_or_else(|| format!("the document has {} revision(s)", ends.len()))?; + let name = format!("{} (revision {n}).pdf", doc.name.trim_end_matches(".pdf")); + let (bytes, password) = (Arc::new(doc.bytes[..end.min(doc.bytes.len())].to_vec()), doc.password.clone()); + self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) + } + + pub(crate) fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { + self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) + } + + /// Apply an edit. On success the previous state is undoable and the view data is refreshed. + pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { + let now = self.now(); + let today = self.today(); + let js_off = self.js_off; + let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); + let doc = self.doc_mut(id)?; + doc.edit_notices.clear(); + let name = doc.name.clone(); + let is_xfa = doc.info.xfa.is_some(); + let mut scope = scope_of(&edit); + let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); + if !js_off { + cx.xfa = doc.xfa_template.clone(); + } + cx.xfa_time_scale = xfa_time_scale; + cx.pages = doc.info.pages.len(); + cx.today = today; + let reason = doc.read_only_reason.clone().unwrap_or_default(); + let signed = doc.is_signed(); + let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; + if let Some(p) = editor.cos.permissions() { + check_permission(&edit, &p)?; + } + // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it + // in the paper's colour and draw the new text in the ink's. + let edit = match edit { + Edit::EditTextBlock { page, block, text, mut style } => { + // Only invisible text is sampled; the blocks are usually cached from opening it. + if style.cover.is_none() + && let Some(info) = doc.info.pages.get(page) + && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) + && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() + { + style.cover = Some(paper); + style.color = style.color.or(Some(ink)); + } + Edit::EditTextBlock { page, block, text, style } + } + other => other, + }; + // Nothing to bake: leave undo history and the dirty flag alone. + if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { + return Ok(()); + } + let mut next = editor.cos.clone(); + if !js_off && uses_scripts(&edit) { + cx.js = Some(js::JsRunner::new(&next, &name)); + } + cx.renamed_fields.clear(); + cx.redaction_overlays.clear(); + // `next` is a copy: if the edit fails or crashes, the document is unchanged. + guard(|| run_edit(&mut next, &edit, &mut cx)) + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + // Redaction removed what its own interpreter places under the marks. The marks were drawn + // over the page as the renderer shows it, so the renderer has the last word. + if let Edit::ApplyRedactions { pages } = &edit { + let marks: Vec = + pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); + guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + } + // Renamed fields are said, not slipped in: a repair-log note (with the caveat that + // scripts were left alone) and a count in the undo step's label. + let renamed_note = |renamed: &[(String, String)]| { + (!renamed.is_empty()).then(|| { + let shown: Vec = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect(); + let more = renamed.len().saturating_sub(shown.len()); + let more = if more > 0 { format!(", and {more} more") } else { String::new() }; + format!( + "inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten", + renamed.len(), + if renamed.len() == 1 { "" } else { "s" }, + shown.join(", ") + ) + }) + }; + if let Some(note) = renamed_note(&cx.renamed_fields) { + next.note_repair(note); + } + // Only Insert from File renames fields; its label says so in a wording the interface + // translates (the count lives in the repair note). + let label = match (&edit, cx.renamed_fields.len()) { + (_, 0) => edit.label(), + (Edit::InsertPagesFrom { name, .. }, n) => Label::with( + if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" }, + name.as_str(), + ), + _ => edit.label(), + }; + // An XFA form's scripts answer the change: exit and validate scripts of the field, then + // every calculation (and a reset recalculates). They read the data, so it is brought up + // to date first. + if let Some(tpl) = cx.xfa.clone() { + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { + let datasets = &mut cx.xfa_datasets; + let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets)) + .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + cx.xfa_out.errors.extend(notes); + } + fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { + match e { + Edit::SetFieldValue { name, .. } => changed.push(name.clone()), + Edit::ResetForm { .. } => *reset = true, + Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), + _ => {} + } + } + let mut changed: Vec = Vec::new(); + let mut reset = false; + collect(&edit, &mut changed, &mut reset); + if !changed.is_empty() || reset { + let mut run = xfa::XfaRun { + page_count: cx.pages, + out: &mut cx.xfa_out, + datasets: cx.xfa_datasets, + ran_away: false, + time_scale: cx.xfa_time_scale, + }; + // Every changed field's scripts, then the calculations once (after a reset, + // only those). + let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) + .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = ran { + cx.xfa_out.errors.push(e); + } + } + } + // XFA forms keep their values in the datasets packet too, for Adobe's viewers. + let mut xfa_notes = Vec::new(); + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { + let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); + xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets)) + .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + // Rebuilding from stale data would discard accepted widget values. + if xfa_notes.is_empty() { + let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form)) + .unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + if let Some(warnings) = reflow { + xfa_notes.extend(warnings); + // Layout replaces pages and widgets, even if the page count is unchanged. + // Undo/redo must rebuild these too instead of refreshing only field values. + scope = Scope::Full; + } + } + } + // Signed documents are only ever saved incrementally: an edit that needs a full rewrite + // (applying redactions, changing security, sanitizing) would invalidate the signatures. + // So does any edit to a file rebuilt from object headers: it has no chain to append to. + let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); + if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { + return Err(EditError::SignedRewrite(edit.label().to_string())); + } + // What this edit drew with `?` (a value or comment text its font can't draw, stored as + // typed): reported, never silent (AGENTS.md §4). + let notices = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) { + guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default() + } else { + Vec::new() + }; + let previous = std::mem::replace(&mut editor.cos, next); + let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); + let previous_keys = std::mem::replace(&mut editor.keys, keys); + editor.undo.push((label, previous, previous_keys, scope)); + if editor.undo.len() > MAX_UNDO { + editor.undo.remove(0); + } + editor.redo.clear(); + Self::adopt_keys(doc); + let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) + .unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + // Roll back: the edit produced something we cannot display. + if let Some(ed) = doc.editor.as_mut() + && let Some((_, prev, keys, _)) = ed.undo.pop() + { + ed.cos = prev; + ed.keys = keys; + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.dirty = true; + doc.generation += 1; + doc.edit_notices = notices; + if !cx.xfa_removed.is_empty() { + let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + note_warnings(&mut doc.xfa_warnings, &removed); + note_warnings(&mut doc.info.warnings, &removed); + } + note_warnings(&mut doc.xfa_warnings, &xfa_notes); + let notes = doc.xfa_warnings.clone(); + note_warnings(&mut doc.info.warnings, ¬es); + if let Some(js) = cx.js { + doc.js_output.append(js.output); + } + doc.js_output.append(cx.xfa_out); + if cx.xfa_ran_away { + doc.xfa_template = None; + doc.xfa_scripts_off = true; + doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } + Ok(()) + } + + pub fn undo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; + let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; + let current = std::mem::replace(&mut editor.cos, prev); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.redo.push((label.clone(), current, current_keys, scope)); + Self::adopt_keys(doc); + let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + if let Some(ed) = doc.editor.as_mut() + && let Some((label, current, current_keys, scope)) = ed.redo.pop() + { + let prev = std::mem::replace(&mut ed.cos, current); + let keys = std::mem::replace(&mut ed.keys, current_keys); + ed.undo.push((label, prev, keys, scope)); + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.edit_notices.clear(); + Self::sync_dirty(doc); + doc.generation += 1; + Ok(label) + } + + pub fn redo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; + let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; + let current = std::mem::replace(&mut editor.cos, next); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.undo.push((label.clone(), current, current_keys, scope)); + Self::adopt_keys(doc); + let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + if let Some(ed) = doc.editor.as_mut() + && let Some((label, current, current_keys, scope)) = ed.undo.pop() + { + let next = std::mem::replace(&mut ed.cos, current); + let keys = std::mem::replace(&mut ed.keys, current_keys); + ed.redo.push((label, next, keys, scope)); + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.edit_notices.clear(); + Self::sync_dirty(doc); + doc.generation += 1; + Ok(label) + } + + /// Unsaved when the working graph is not the last opened or saved file. Undo back to that + /// file is clean; undo past a later save is not, because the restored snapshot's own base + /// is an earlier revision. + fn sync_dirty(doc: &mut Document) { + doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); + } + + /// Use the current state's password for viewing (protection edits change it). + fn adopt_keys(doc: &mut Document) { + if let Some(e) = doc.editor.as_ref() { + doc.password = e.keys.render.clone(); + doc.config.password = e.keys.render.as_deref().map(Arc::from); + } + } + + /// Rebuild the working bytes and the view data that `scope` may have changed. Metadata edits + /// keep the renderer; other scopes also replace it. + /// Comment and form edits skip the full re-inspection (seconds on very large files): the + /// comment list and field values are re-read from the object graph instead. + fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> { + if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) { + return Self::refresh(doc); + } + if scope == Scope::Content { + return Self::refresh_content(doc, None); + } + if scope == Scope::Links { + return Self::refresh_links(doc); + } + // Metadata edits keep the renderer, which holds the working file anyway. + if scope != Scope::Metadata { + Self::release_working(doc); + } + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + // Comment edits, and form edits (a script may change annotation properties), may change + // annotation dictionaries, invalidating cached appearances. + if matches!(scope, Scope::Comments | Scope::Form) { + editor.appearances.clear(); + } + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + if scope == Scope::Metadata { + for (field, value) in info_fields(&mut doc.info, &editor.cos) { + *field = value; + } + doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); + doc.info.file_size = bytes.len(); + #[cfg(test)] + check_refresh_panic(); + if !doc.signatures.is_empty() { + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.signatures = Arc::new(signatures); + } + doc.bytes = bytes; + return Ok(()); + } + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + let form = Arc::new(form); + match scope { + Scope::Metadata => return Ok(()), + Scope::Comments => { + doc.info.annotations = comment_list(&editor.cos); + // Link indexes are positions in /Annots, so comment insertion/deletion can move them. + doc.links = OnceLock::new(); + } + Scope::Form => { + // PERF: keystroke path, index once. A linear find per field is O(F^2); + // forms reach 10k fields. + let mut by_name = std::collections::HashMap::with_capacity(form.len()); + for x in form.iter() { + // First match wins (do not simplify to collect: it keeps the last). + by_name.entry(x.name.as_str()).or_insert(x); + } + for f in &mut doc.info.fields { + if let Some(ff) = by_name.get(f.name.as_str()) { + f.value = match ff.kind { + pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => { + Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) + } + _ if ff.value.is_empty() => None, + _ => Some(ff.value.join(", ")), + }; + } + } + } + Scope::Links => return Self::refresh_links(doc), + // Handled above; kept as a full refresh so the match stays total. + Scope::Full => return Self::refresh(doc), + // Handled above (content-only refresh); the arm keeps the match total. + Scope::Content => return Self::refresh_content(doc, None), + } + doc.info.file_size = bytes.len(); + doc.form = form; + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.bytes = bytes; + Ok(()) + } + + /// Which derived lists a content-only edit can change: added content (`None`, from + /// undo/redo, re-reads both: always correct, rarely paid). + pub(crate) fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) { + if let Some(Edit::Batch { edits, .. }) = edit { + // A batch of content edits (from a script, say) changes what its parts change. + return edits.iter().fold((false, false), |(added, marks), part| { + let (a, m) = Self::content_refresh_lists(Some(part)); + (added || a, marks || m) + }); + } + let touches_added = edit.is_none_or(|e| { + matches!( + e, + Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) + }); + let touches_marks = edit.is_none_or(|e| { + matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) + }); + (touches_added, touches_marks) + } + + /// Rebuild working bytes, display bytes and renderer after a content-only edit: page + /// content streams, resources and added content/marks. The page tree, annotations, form + /// fields, links, outline and fonts are unchanged, so the full re-inspection (seconds on + /// very large files) is skipped. `edit` selects which derived lists are re-read (`None`, + /// from undo/redo, re-reads both: always correct, rarely paid). The cached display + /// appearances are kept: content edits leave every annotation dictionary alone. + fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> { + Self::release_working(doc); + sync_xfa_state(doc); + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + // The edit must still leave a file the viewer can open; anything else rolls back above. + pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?; + let (touches_added, touches_marks) = Self::content_refresh_lists(edit); + if touches_added || touches_marks { + doc.marker_scan = OnceLock::new(); + } + doc.info.file_size = bytes.len(); + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.bytes = bytes; + doc.fonts_stale = true; + Ok(()) + } + + /// Let go of the working file before the next one is written (gap 13). The working file is + /// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the + /// working bytes, the display bytes and the renderer) while writing the new one held three + /// copies at once; this keeps two. Until the refresh finishes, the working and display bytes + /// are the original's (already held by the editor, so free) and the renderer draws nothing. + /// A failed edit rolls back and refreshes again, which replaces all three; where a refresh + /// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes + /// from the editor, never from these. + pub(crate) fn release_working(doc: &mut Document) { + let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return }; + // Dropping the pool stops its workers; each lets go of the file when it exits. + doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone()); + doc.display = original.clone(); + doc.bytes = original; + #[cfg(test)] + check_refresh_panic(); + } + + /// After a refresh failed: draw the display bytes it left (the original, once released), + /// so the page still shows a document. + fn show_released(doc: &mut Document) { + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + } + + /// Rebuild working bytes, inspection and renderer from the current edit state. + pub(crate) fn refresh(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, true, true) + } + + /// Reinspect a link edit while preserving the page-content marker scan cache. + fn refresh_links(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, false, true) + } + + fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { + // A full refresh may follow structural or annotation changes: cached appearances + // may be stale, so they are rebuilt below. + if let Some(editor) = doc.editor.as_mut() { + editor.appearances.clear(); + } + Self::release_working(doc); + sync_xfa_state(doc); + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + let info = inspect(bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + // Keep the user's layer choices where the layers still exist. + let mut info = info; + for l in &mut info.layers { + if let Some(old) = doc.info.layers.iter().find(|o| o.id == l.id) { + l.visible = old.visible; + } + } + note_warnings(&mut info.warnings, &doc.xfa_warnings); + // As in `build_document`: the inspection reads only `/Info`, so fields stated only in XMP + // are filled in again after every full refresh. + for (field, value) in info_fields(&mut info, &editor.cos) { + if field.is_none() { + *field = value; + } + } + doc.info = info; + doc.fonts_stale = false; + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + doc.form = Arc::new(form); + if invalidate_markers { + doc.marker_scan = OnceLock::new(); + } + if invalidate_links { + doc.links = OnceLock::new(); + } + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.bytes = bytes; + Ok(()) + } + + /// The bytes to write for Save: an incremental update of the file as opened/last saved, + /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully. + /// + /// A file whose cross-reference data had to be rebuilt is written in full even when nothing + /// was edited: its bytes as opened are the damaged file, and this save is how the repair the + /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would + /// break its signatures, so with no edits it keeps its bytes, damage and all. + pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; + let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); + if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { + return Ok(editor.saved.clone()); + } + // What the user removed again before saving (a cleared redaction mark) stays out of the file. + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), drop_unlinked: true, ..SaveOptions::default() }; + // A restored snapshot can be based on an earlier revision. Append to the last saved + // file so that revision stays in the chain. A full rewrite (redaction, new encryption, + // a reconstructed file) still writes the current graph on its own. + let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); + let cos = writable(&editor.cos)?; + guard(|| { + if on_saved || rewrite { + write_incremental(&cos, &opts) + } else { + let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; + base.overlay_changes_from(&cos)?; + write_incremental(&base, &opts) + } + }) + .map_err(EditError::Write)? + .map(Arc::new) + .map_err(|e| EditError::Write(e.to_string())) + } + + /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). + pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if doc.is_signed() { + return Err(EditError::Signed); + } + let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let cos = writable(&editor.cos)?; + guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + } + + /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this + /// document before they are written (#1698): [`Self::mark_saved`] only finds out after the + /// file has been replaced. Same error as `mark_saved`. + pub fn check_save(&self, id: DocId, bytes: &Arc>) -> Result<(), EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; + pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string())) + } + + /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file + /// so the next save appends only newer edits. Undo history is kept. + pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + if let Some(editor) = doc.editor.as_mut() { + // An encrypted file needs the password it is protected with now (the owner + // password when known, so saving never downgrades this session's rights). + let keys = editor.keys.clone(); + // The saved file carries no Password-field values (#3943, #3944); the open document keeps them. + let typed = pdfcraft_forms::password_values(&editor.cos); + editor.cos = + pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; + if !typed.is_empty() && doc.info.xfa.is_some() { + let mut datasets = None; + let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets); + } + editor.saved = bytes; + editor.saved_keys = keys; + editor.unsaved_file = false; + } + if let Some(p) = path { + doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); + doc.path = Some(p); + } + doc.dirty = false; + doc.generation += 1; + Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + } + + /// Enter the permissions (owner) password of a document opened with its user password, + /// lifting its restrictions for this session (Acrobat asks for it before changing security). + /// Not an edit: nothing is marked unsaved and there is no undo step. Undo and redo states + /// are unlocked as well, and saving keeps the rights. Unencrypted documents and documents + /// opened by the owner need nothing. + pub fn unlock(&mut self, id: DocId, password: &str) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + if editor.cos.permissions().is_none_or(|p| p.owner) { + return Ok(()); + } + editor.cos.unlock(password).map_err(|e| match e { + pdfcraft_cos::CosError::WrongPassword => EditError::WrongPassword, + other => EditError::Protection(other.to_string()), + })?; + // Saving a state that keeps the security it was opened with reopens it with the same + // passwords, so it reopens as owner. + let reopen_as_owner = |cos: &pdfcraft_cos::Document, keys: &mut Keys| { + if !cos.encryption_changed() { + keys.reopen = Some(password.to_owned()); + } + }; + reopen_as_owner(&editor.cos, &mut editor.keys); + // Other states with the same restricted access are unlocked too; states opened with + // other passwords (protection changed and saved since) stay as they are. + for (_, cos, keys, _) in editor.undo.iter_mut().chain(editor.redo.iter_mut()) { + if cos.permissions().is_some_and(|p| !p.owner) && cos.unlock(password).is_ok() { + reopen_as_owner(cos, keys); + } + } + Ok(()) + } + + /// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is + /// cleared, as in Acrobat. + pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + let base = editor.saved.clone(); + // The saved file opens with the passwords it was saved with. The current snapshot may + // be an older revision (undo past that save), so its own base is not the saved file. + let mut candidates = vec![editor.saved_keys.clone(), editor.keys.clone()]; + candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone())); + candidates.push(Keys::default()); + let (cos, keys) = candidates + .into_iter() + .find_map(|k| pdfcraft_cos::Document::open_with_password(base.clone(), k.reopen.as_deref()).ok().map(|c| (c, k))) + .ok_or_else(|| EditError::Reopen("the saved file can't be opened".into()))?; + editor.cos = cos; + editor.keys = keys; + editor.undo.clear(); + editor.redo.clear(); + Self::adopt_keys(doc); + Self::sync_dirty(doc); + doc.generation += 1; + Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + } + + /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open + /// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA + /// layout and scripts, signatures, repairs), but the document keeps its id, name and path, + /// so its tab and view stay with it. Undo history goes, as with Revert. The password it was + /// opened with is tried; on any failure the document is left as it was. + pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { + let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; + let doc = &self.docs[old]; + let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); + let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); + let new_id = self.open(name, path, bytes, password.as_deref())?; + let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?; + let mut doc = self.docs.remove(at); + doc.id = id; + // How the document is shown is the reader's, not the file's: reading colours and hidden + // comments stay as they were (#3443). `open` built the renderer with the defaults. + if let Some(old) = self.docs.get(old) + && (doc.config.reading_colors != old.config.reading_colors || doc.config.hide_comments != old.config.hide_comments) + { + doc.config.reading_colors = old.config.reading_colors; + doc.config.hide_comments = old.config.hide_comments; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + } + // Caches keyed by the edit generation (edit lines, images, measurements) must see a new + // one; there is nothing new to autosave. + doc.generation = generation + 1; + doc.snapshot_generation = doc.generation; + // `open` pushed the new version last, so the old one is still at `old`. + *self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc; + Ok(()) + } + + /// The page count of another PDF (Replace Pages, Insert Pages dialogs). + pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { + Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?) + } + + /// A new blank document (Create ▸ Blank page). + pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { + self.write_new(&pdfcraft_create::blank(width, height, pages)?) + } + + /// A new document with one page per image (PNG, JPEG). + pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images(images)?) + } + + /// Create image pages at embedded resolution or a fixed dpi, without resampling. + pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) + } + + /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is + /// refused, naming the line and the character (never set as `?`). + pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) + } + + /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, + /// compare reports), which can quote any language: characters Helvetica can't draw are written + /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. + pub(crate) fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { + let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; + self.write_new(&doc) + } + + /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked + /// (it must open and allow copying pages) and returned as it is. + pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { + let Some(kind) = source_kind(name, bytes) else { + return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); + }; + let title = name.rsplit_once('.').map_or(name, |(s, _)| s); + // Image decoders read untrusted bytes: a panic in one must not take the app down. + let created = guard(|| match kind { + SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), + SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), + SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), + }) + .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; + Ok((kind, created?)) + } + + /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as + /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf + /// without a `%PDF-` header, which the reader may still open. + pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { + let pdf = match source_kind(name, bytes) { + Some(SourceKind::Pdf) => true, + Some(SourceKind::Image | SourceKind::Text) => false, + None => name.to_ascii_lowercase().ends_with(".pdf"), + }; + if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } + } + + /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium + /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects + /// packed into compressed object streams. Returns the bytes and how many objects were + /// merged. The open document is not changed (Acrobat saves the reduced copy as a new file). + pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { + let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; + Ok((bytes, report.merged)) + } + + /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove + /// Hidden Information's `discard` categories (user data). A full rewrite: signed documents + /// are refused. The open document is not changed. This is [`Self::optimize_job`] run in + /// place, without progress. + pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc>, OptimizeReport), EditError> { + self.optimize_job(id, settings, discard)?.run(|_| true) + } + + /// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text + /// are form data only. + pub fn export_data(&self, id: DocId, format: DataFormat, comments: bool, fields: bool) -> Result, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => { + pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? + } + }; + // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. + let xfa = doc.info.xfa.is_some(); + if format == DataFormat::Xdp && !xfa { + return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); + } + guard(|| match format { + DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), + DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()), + DataFormat::Xml | DataFormat::Xdp if xfa => { + pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) + } + other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), + }) + .map_err(EditError::Write)? + .map_err(EditError::Write) + } + + /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). + pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if !doc.allows_printing() { + return Err(EditError::NotPermitted("printing")); + } + // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages + // always go as pictures, at no more than the low resolution. + let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { + dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), + grayscale: settings.raster.is_some_and(|r| r.grayscale), + }); + // Print as image: the pages used become pictures first, then the picture document is + // imposed in their place (its page k is the k-th distinct source page used). + if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) { + let mut distinct: Vec = Vec::new(); + for &p in &settings.pages { + if !distinct.contains(&p) { + distinct.push(p); + } + } + let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?; + let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; + let remapped = print::Settings { + pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), + content: print::Content::Document, + raster: None, + ..settings.clone() + }; + return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); + } + let mut cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => { + pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? + } + }; + // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer + // action) has changed one, the print copy's default configuration says which are on, so a + // hidden layer stays off on paper too; untouched, the document's own configuration prints. + if !doc.config.layers.is_empty() { + set_default_layer_state(&mut cos, &doc.info.layers); + } + pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) + } + + /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); + /// only `pages` are read, the others are `false`. + pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { + let Some(doc) = self.get(id) else { return Vec::new() }; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { + Ok(c) => c, + Err(_) => return Vec::new(), + }, + }; + (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() + } + + /// Combine whole files, in order, into new PDF bytes (one bookmark per file). + pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { + let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; + let named: Vec<(&str, &pdfcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect(); + let out = pdfcraft_organize::combine(&named)?; + self.write_new(&out) + } + + /// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages). + pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result>, EditError> { + self.combine_unlocked(sources, &[]) + } + + /// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as + /// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see + /// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a + /// time: a source is opened, its pages are copied into the result, and its bytes are released + /// before the next item is read. So the number of sources is not limited, and one source's + /// document is held at a time. An item's error stops the combine and is returned as it is. + /// `Ok(None)` when there are no sources. + pub fn combine_each(&self, sources: I) -> Result>>, E> + where + I: IntoIterator>, + E: From, + { + let mut combiner = pdfcraft_organize::Combiner::new(); + let mut added = false; + for source in sources { + let (title, bytes, range) = source?; + let doc = open_source(&title, &bytes)?; + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let pages = match range { + Some(r) => { + let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{title}: {e}")))?; + Some(p) + } + None => None, + }; + combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?; + added = true; + } + if !added { + return Ok(None); + } + let out = combiner.finish().map_err(EditError::from)?; + Ok(Some(self.write_new(&out)?)) + } + + /// [`Self::combine_ranges`] with the password each encrypted source is opened with, by + /// position (missing or `None`: no password). The result is not encrypted. + pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result>, EditError> { + Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) + } + + /// The pre-flight over byte sources: what combining them, in order, would rename in each one + /// (whole files; page ranges are the combine's to apply). A source that cannot be opened + /// previews as nothing — its row already says why it cannot be combined. + pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec> { + let docs: Vec> = + sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect(); + let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(Option::as_ref).collect(); + let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter(); + // One preview per source, in step with it: a file that cannot be opened has none. + docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect() + } + + /// [`Self::combine_unlocked`] reporting what the conflict resolution renamed. + pub fn combine_unlocked_reporting( + &self, + sources: &[CombineSource], + passwords: &[Option<&str>], + field_conflicts: pdfcraft_organize::FieldConflicts, + ) -> Result { + let docs = sources + .iter() + .enumerate() + .map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten())) + .collect::, _>>()?; + let mut pages = Vec::with_capacity(docs.len()); + for ((name, _, range), d) in sources.iter().zip(&docs) { + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + pages.push(match range { + Some(r) => { + let n = pdfcraft_organize::page_count(d)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{name}: {e}")))?; + Some(p) + } + None => None, + }); + } + let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> = + sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect(); + let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?; + let bytes = self.write_new(&out.doc)?; + Ok(Combined { bytes, renamed_fields: out.renamed_fields }) + } + + /// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one + /// file split into several runs placed apart: each file is opened and copied once, so it keeps + /// one bookmark, its links between its own pages, its fields and its attachments whole (see + /// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes + /// and password; a source without a key is a file of its own. + pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result>, EditError> { + Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) + } + + /// [`Self::combine_grouped`] reporting what the conflict resolution renamed. + pub fn combine_grouped_reporting( + &self, + sources: &[CombineSource], + groups: &[u64], + passwords: &[Option<&str>], + field_conflicts: pdfcraft_organize::FieldConflicts, + ) -> Result { + let (mut keys, mut docs, mut file): (Vec>, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new()); + for (i, (name, bytes, _)) in sources.iter().enumerate() { + let key = groups.get(i).copied(); + let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k))); + let at = match known { + Some(at) => at, + None => { + docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?); + keys.push(key); + docs.len() - 1 + } + }; + file.push(at); + } + let mut pages = Vec::with_capacity(sources.len()); + for ((name, _, range), at) in sources.iter().zip(&file) { + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; + pages.push(match range { + Some(r) => { + let n = pdfcraft_organize::page_count(d)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{name}: {e}")))?; + Some(p) + } + None => None, + }); + } + let mut runs: Vec> = Vec::with_capacity(sources.len()); + for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) { + let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; + runs.push((*at, name.as_str(), d, p.as_deref())); + } + let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?; + let bytes = self.write_new(&out.doc)?; + Ok(Combined { bytes, renamed_fields: out.renamed_fields }) + } + + /// New PDF bytes containing copies of `pages` of the document (Extract Pages). + pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("extracting pages")); + } + let out = pdfcraft_organize::extract_pages(src, pages)?; + self.write_new(&out) + } + + /// Split the document into several new PDFs. + pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("splitting the document")); + } + let n = pdfcraft_organize::page_count(src)?; + pdfcraft_organize::split_ranges(n, by) + .into_iter() + .map(|r| { + let doc = pdfcraft_organize::extract_pages(src, &r.clone().collect::>())?; + Ok((r.start + 1, r.end, self.write_new(&doc)?)) + }) + .collect() + } + + /// Split into parts of at most `max_bytes` each (Acrobat's "File size"): pages are taken in + /// order while their size (as single-page files, an over-estimate because shared fonts and + /// images count once per part) fits; a page larger than the limit is a part of its own. + pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("splitting the document")); + } + let n = pdfcraft_organize::page_count(src)?; + let mut cuts = Vec::new(); + let mut used = 0usize; + for p in 0..n { + let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); + if used > 0 && used + size > max_bytes { + cuts.push(p); + used = 0; + } + used += size; + } + self.split(id, &pdfcraft_organize::SplitBy::Before(cuts)) + } + + /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle + /// per line in user space, URI). Text that already has a link over it is skipped. + pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { + let Some(doc) = self.get(id) else { return Vec::new() }; + let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); + let mut out = Vec::new(); + for page in 0..doc.info.pages.len() { + let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + let Some(text) = res.text else { continue }; + let found: std::cell::RefCell, String)>> = Default::default(); + let hits = text.find_with(|chars| { + let urls = pdfcraft_annot::links::find_urls(chars); + let ranges = urls.iter().map(|u| u.0.clone()).collect(); + *found.borrow_mut() = urls; + ranges + }); + for (glyphs, (_, uri)) in hits.into_iter().zip(found.into_inner()) { + let rects: Vec<[f64; 4]> = text + .line_rects(glyphs) + .into_iter() + .map(|v| { + let q = doc.info.pages[page].view_rect_to_quad(v); + let xs = [q[0], q[2], q[4], q[6]]; + let ys = [q[1], q[3], q[5], q[7]]; + [ + xs.iter().copied().fold(f64::MAX, f64::min), + ys.iter().copied().fold(f64::MAX, f64::min), + xs.iter().copied().fold(f64::MIN, f64::max), + ys.iter().copied().fold(f64::MIN, f64::max), + ] + }) + .filter(|r| { + !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) + }) + .collect(); + if !rects.is_empty() { + out.push((page, rects, uri)); + } + } + } + out + } + + /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. + /// The document's own structure is read for it, so every bookmark can be reached by paging. + pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { + let doc = self.get(id)?; + if let Some(editor) = doc.editor.as_ref() { + let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit); + let rows = page + .bookmarks + .into_iter() + .map(|b| { + let view = b.view.map_or_else(Default::default, |(kind, numbers)| { + let numbers: Vec> = numbers.iter().map(|n| n.map(|n| n as f32)).collect(); + pdfcraft_render::DestView::from_operands(Some(&kind), &numbers) + }); + BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold } + }) + .collect(); + return Some(BookmarkPage { rows, next: page.next, truncated: false }); + } + let all = outline_rows(&doc.info.outline); + let end = offset.saturating_add(limit); + let rows = all.iter().skip(offset).take(limit).cloned().collect(); + Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) + } + + /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). + pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { + let Some(doc) = self.get(id) else { return Vec::new() }; + let top: Vec<(Option, String)> = match doc.editor.as_ref() { + Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(), + None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(), + }; + let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect(); + out.sort_by_key(|x| x.0); + out.dedup_by_key(|x| x.0); + out + } + + /// Open freshly created bytes (combine / extract) as a new, unsaved document. + pub fn open_new(&mut self, name: impl Into, bytes: Arc>) -> Result { + let id = self.open(name, None, bytes, None)?; + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } + // Unsaved work with no edit yet still needs its first recovery snapshot. + d.generation += 1; + } + Ok(id) + } + + fn cos(&self, id: DocId) -> Result<&pdfcraft_cos::Document, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default())) + } + + /// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone + /// writes it (#1698, #2166): it reopens, and shows the pages it was built from. + fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { + let bytes = self.write_unchecked(doc)?; + let expected = pdfcraft_organize::page_count(doc)?; + pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?; + Ok(bytes) + } + + /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. + /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and + /// Split copy the fields of the pages they take. + fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let doc = writable(doc)?; + write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + } + + /// Whether a changed document still needs a recovery snapshot. This does not consume it. + pub fn has_pending_autosave(&self) -> bool { + self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation) + } + + /// Documents with unsaved changes made since the last call: their current working file, + /// for crash recovery. Encrypted documents stay encrypted in the snapshot. + pub fn autosave_snapshots(&mut self) -> Vec { + let mut out = Vec::new(); + for d in &mut self.docs { + if d.dirty && d.generation != d.snapshot_generation { + // The working bytes hold typed Password-field values (#3926, #3944): a recovery file + // is written from a copy without them, and none is written if that copy fails. + let bytes = + match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) { + Some(e) => match writable(&e.cos) + .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) + { + Ok(b) => Arc::new(b), + Err(_) => continue, + }, + None => d.bytes.clone(), + }; + d.snapshot_generation = d.generation; + out.push(RecoverySnapshot { + doc: d.id, + name: d.name.clone(), + path: d.path.clone(), + bytes, + encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), + }); + } + } + out + } + + /// Mark a document opened from a recovery file: it has unsaved changes and belongs at + /// `path` (where Save writes), as when the session ended. + pub fn mark_recovered(&mut self, id: DocId, path: Option) { + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + d.path = path; + d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } + d.generation += 1; + d.snapshot_generation = d.generation; // its bytes are already in the recovery store + } + } + + /// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed; + /// callers must drop cached rasters and text for the document. + pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + let Some(l) = doc.info.layers.get_mut(layer) else { return false }; + if l.visible == visible { + return false; + } + l.visible = visible; + use_layer_choices(doc); + true + } + + /// Run a set-layer-visibility action (`SetOCGState`): apply `changes` in order, naming each + /// layer by its optional content group; groups that aren't layers are skipped. With + /// `preserve_rb`, a layer turned on turns off the other layers of its radio-button groups. + /// Returns `true` if any layer changed; callers must drop cached rasters and text for the + /// document, as for [`Self::set_layer_visible`]. + pub fn set_layer_state(&mut self, id: DocId, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if !apply_layer_state(&mut doc.info.layers, &doc.info.layer_groups, changes, preserve_rb) { + return false; + } + use_layer_choices(doc); + true + } + + /// Show each layer as the file opens (Acrobat's Layers ▸ Reset to Initial Visibility). Returns + /// `true` if any layer changed; callers must drop cached rasters and text for the document, + /// as for [`Self::set_layer_visible`]. + pub fn reset_layers(&mut self, id: DocId) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + let mut changed = false; + for l in &mut doc.info.layers { + changed |= l.visible != l.initial; + l.visible = l.initial; + } + if changed { + use_layer_choices(doc); + } + changed + } + + /// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form + /// fields and links still draw. Returns whether anything changed. + pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.hide_comments == hide { + return false; + } + doc.config.hide_comments = hide; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.generation += 1; + true + } + + /// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether + /// anything changed; callers must drop cached rasters and text for the document. + pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.reading_colors == colors { + return false; + } + doc.config.reading_colors = colors; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.generation += 1; + true + } + + /// The document's reading-colours setting; `None` when no such document is open. + pub fn reading_colors(&self, id: DocId) -> Option { + Some(self.get(id)?.config.reading_colors) + } + + /// How the document's pages are drawn for viewing: its password and view settings (layer + /// visibility, hidden comments, reading colours). `None` when no such document is open. + pub fn render_config(&self, id: DocId) -> Option { + Some(self.get(id)?.config.clone()) + } + + /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment + /// with its number, author, type, date, text and replies, grouped by page. + pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let text = comment_summary(&doc.name, &doc.info.annotations, sort); + self.create_report(&format!("Summary of Comments on {}", doc.name), &text) + } + + /// The certificates trusted for signing. + pub fn trusted_certificates(&self) -> &[pdfcraft_sign::Certificate] { + &self.trust.certs + } + + /// Replace the trusted certificates, revalidate every open document's signatures, + /// and rebuild its display copy and renderer to match (working file untouched, + /// generation/dirty unchanged: trust alone is not an edit). + pub fn set_trusted_certificates(&mut self, certs: Vec) { + self.trust = Arc::new(TrustStore { certs }); + for doc in &mut self.docs { + doc.trust = self.trust.clone(); + let Some(editor) = doc.editor.as_mut() else { continue }; + // Keep verdict refresh and renderer refresh together. Viewer caches are + // invalidated by the UI trust path; trust alone changes no document artwork. + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &doc.bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + } + } + + /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). + pub fn signing_date(&self) -> String { + let offset = self.local_offset(); + let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset); + let stamp = local.trim_end_matches('Z'); + if offset == 0 { + return format!("{stamp}Z"); + } + let (sign, m) = if offset < 0 { ('-', -offset / 60) } else { ('+', offset / 60) }; + format!("{stamp}{sign}{:02}'{:02}'", m / 60, m % 60) + } + + /// Sign the document's current state with `id` (Use a certificate ▸ Digitally sign). Returns + /// the signed file; the caller saves it and then calls [`Session::mark_signed`]. An empty + /// `opts.date` takes the session clock. + pub fn sign(&self, doc: DocId, id: &pdfcraft_sign::DigitalId, mut opts: SignOptions) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + // Encrypted documents are signed under their permissions (the signer checks them). + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + if opts.date.is_empty() { + opts.date = self.signing_date(); + } + // A typed Password-field value is not signed into the file (#3926), as it isn't saved. + let cos = writable(&editor.cos)?; + Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) + } + + /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by + /// `tsa`. The transport lives with the caller; the engine never opens a socket. + pub fn sign_with_timestamp( + &self, + doc: DocId, + id: &pdfcraft_sign::DigitalId, + mut opts: SignOptions, + tsa: &dyn pdfcraft_sign::TimestampAuthority, + ) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + if opts.date.is_empty() { + opts.date = self.signing_date(); + } + let cos = writable(&editor.cos)?; + let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?); + // Checked like a save before anyone writes it (#2166). + self.check_save(doc, &signed)?; + Ok(signed) + } + + /// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's + /// current state. An empty `date` takes the session clock; the transport is the caller's. + pub fn timestamp_document(&self, doc: DocId, tsa: &dyn pdfcraft_sign::TimestampAuthority, date: String) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + let date = if date.is_empty() { self.signing_date() } else { date }; + let cos = writable(&editor.cos)?; + let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); + self.check_save(doc, &stamped)?; + Ok(stamped) + } + + /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature + /// (PAdES B-LT): an incremental update that never rewrites signed bytes. + pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + let cos = writable(&editor.cos)?; + let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); + self.check_save(doc, &embedded)?; + Ok(embedded) + } + + /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], + /// and the edit history before signing is dropped (signing can't be undone). + pub fn mark_signed(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { + self.mark_saved(id, bytes, path)?; + let doc = self.doc_mut(id)?; + if let Some(e) = doc.editor.as_mut() { + e.undo.clear(); + e.redo.clear(); + } + Ok(()) + } + + pub fn close(&mut self, id: DocId) { + self.docs.retain(|d| d.id != id); + } + + pub fn get(&self, id: DocId) -> Option<&Document> { + self.docs.iter().find(|d| d.id == id) + } + + /// Read document `id`'s font list again if content edits since the last full inspection may + /// have changed it (Document Properties ▸ Fonts, `doc_info`). A no-op otherwise. + pub fn refresh_fonts(&mut self, id: DocId) { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id && d.fonts_stale) else { return }; + if let Ok(info) = pdfcraft_render::inspect(doc.bytes.clone(), doc.password.as_deref()) { + doc.info.fonts = info.fonts; + } + doc.fonts_stale = false; + } + + pub fn docs(&self) -> &[Document] { + &self.docs + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 360517a87c..d06faefc8b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,6 +1,8 @@ use std::sync::Arc; +use super::edit::check_permission; use super::*; +use pdfcraft_render::{OpenError, ReadingColors}; /// A minimal `n`-page document; page `i` shows "Page i+1". pub(crate) fn fixture(n: usize) -> Vec { From c4dc7e91268577bc74507ee5961ae0754f6a7fc9 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 22:29:41 +0100 Subject: [PATCH 12/28] refactor(engine): keep the moved docs' intra-doc links resolving Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/edit.rs | 6 +++--- crates/engine/src/history.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index a29e4a870b..faf4712078 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -364,7 +364,7 @@ pub enum Edit { activity: String, }, /// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the - /// image (from [`ocr::OcrJob::run`]). + /// image (from [`ocr::OcrJob::run`](crate::ocr::OcrJob::run)). AddOcrText { page: usize, words: Vec, @@ -409,7 +409,7 @@ pub enum Edit { earlier: bool, }, /// Set the calculation order: these calculated fields first, in this order, then the others - /// in their current order (see [`Document::calculation_order`]). The form recalculates. + /// in their current order (see [`Document::calculation_order`](crate::Document::calculation_order)). The form recalculates. SetCalculationOrder { names: Vec, }, @@ -469,7 +469,7 @@ pub enum Edit { }, /// Edit ▸ Paste: an added item copied in this document or another, as it was: text with its /// style, an image in its box (display space) with its turns, flips and crop. An image's - /// picture is `file` (from [`Document::added_image_file`]): the copied image object is reused + /// picture is `file` (from [`Document::added_image_file`](crate::Document::added_image_file)): the copied image object is reused /// while it is still that picture in this document, else one an added image here already draws /// it from, else the file is embedded. PasteContent { diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 086d98a6ab..fafa65b4c2 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -169,7 +169,7 @@ impl Editor { /// the two cannot drift apart. #[derive(Clone, Debug, PartialEq)] pub struct Label { - /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`]. + /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`](crate::EDIT_LABELS). pub wording: &'static str, /// What the document supplies, when the wording has a hole for it. pub value: Option, From ff361fe13837515ec1fb2690704079c31d66978b Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:05:07 +0100 Subject: [PATCH 13/28] refactor(engine): move Document and its display helpers to document.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/document.rs | 713 ++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 708 +-------------------------------- crates/engine/src/tests.rs | 1 + 3 files changed, 719 insertions(+), 703 deletions(-) create mode 100644 crates/engine/src/document.rs diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs new file mode 100644 index 0000000000..0c85f51af6 --- /dev/null +++ b/crates/engine/src/document.rs @@ -0,0 +1,713 @@ +//! An open document: its working file, view data and edit state. + +use std::sync::{Arc, OnceLock}; + +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{DocInfo, RenderConfig, RenderPool}; + +use crate::history::Editor; +use crate::{ + CalculationOrder, CheckStyle, DocId, Edit, EditError, EditableObject, FormFieldKind, Hidden, InitialView, Label, MarkKind, SecuritySummary, + SignatureInfo, TabOrder, TrustStore, XfaLayout, a11y, form_appearance, guard, js, measure, optimize, renderer_password, unrestricted, +}; +#[cfg(test)] +use crate::{record_link_scan, record_marker_scan}; + +pub struct Document { + pub id: DocId, + pub name: String, + pub path: Option, + /// The working file: what Save writes. + pub bytes: Arc>, + /// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances + /// drawn for display only for comments that have none ([`display_bytes`]). Never saved. + pub display: Arc>, + pub info: DocInfo, + pub renderer: RenderPool, + /// The password the document was opened with (needed to read attachments, etc.). + pub password: Option, + /// `true` when there are edits that have not been saved. + pub dirty: bool, + /// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it. + pub(crate) generation: u64, + /// The generation last handed out by `autosave_snapshots`. + pub(crate) snapshot_generation: u64, + /// Why the document cannot be edited (e.g. encryption), if so. + pub read_only_reason: Option, + /// Interactive form fields of the current state (empty without a form). + pub form: Arc>, + pub(crate) marker_scan: OnceLock, + pub(crate) links: OnceLock>, + /// Signature fields, validated against the session's trust store (Signatures panel). + pub signatures: Arc>, + pub(crate) trust: Arc, + pub(crate) sig_cache: Arc, + pub(crate) editor: Option, + pub(crate) config: RenderConfig, + /// What field scripts printed or asked for (see [`Session::take_js_output`]). + pub(crate) js_output: js::JsOutput, + /// Dynamic XFA forms: what laying the template out produced (pages and fields are + /// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves). + pub xfa: Option, + /// The parsed template of a laid-out XFA form, for its scripts. + pub(crate) xfa_template: Option>, + /// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]). + pub(crate) xfa_scripts_off: bool, + /// A content-only refresh keeps `info` from before the edit; an edit may have added or + /// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]). + pub(crate) fonts_stale: bool, + /// XFA forms: what was approximated, rewritten or could not be written to the XFA data + /// (also in `info.warnings`, kept there when the document is re-read). + pub xfa_warnings: Vec, + /// What the last successful [`Session::apply`] wrote that its fonts can't draw: kept as typed, + /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. + pub edit_notices: Vec, +} + +/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn +/// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat +/// keeps such text too, drawing it with fonts PdfCraft can't embed yet. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextNotice { + /// The form field's full name, or `None` for a comment. + pub field: Option, + /// The page (0-based) of the comment or of the field's widget. + pub page: usize, + pub ch: char, + /// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances` + /// set); otherwise the `?` shows in every viewer. + pub redrawn_elsewhere: bool, +} + +impl std::fmt::Display for TextNotice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch)); + let page = self.page + 1; + match (&self.field, self.redrawn_elsewhere) { + (Some(name), true) => write!( + f, + "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value" + ), + (Some(name), false) => write!( + f, + "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere" + ), + (None, _) => write!( + f, + "a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer" + ), + } + } +} + +/// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying +/// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order. +pub(crate) fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { + let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() }; + let redrawn_elsewhere = pdfcraft_forms::need_appearances(next); + let mut names: Option> = None; + let mut out: Vec = Vec::new(); + for (page, p) in pages.iter().enumerate() { + let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a)); + for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) { + let now = next.get(r); + let Some(d) = now.as_dict() else { continue }; + let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue }; + // Only what this edit drew: a new annotation, or a new appearance. + let was = prev.get(r); + if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) { + continue; + } + let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| { + let names = names.get_or_insert_with(|| { + pdfcraft_forms::fields(next) + .into_iter() + .flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()) + .collect() + }); + names.get(&r).cloned().unwrap_or_default() + }); + if field.is_some() && out.iter().any(|n| n.field == field) { + continue; + } + out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch }); + } + } + out +} + +impl Document { + /// How the document opens (Document Properties ▸ Initial View). + /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be + /// read for editing). + pub fn accessibility_check(&self, options: &a11y::Options) -> Option { + self.editor.as_ref().map(|e| a11y::check(&e.cos, options)) + } + + /// A script of this XFA form ran past its time limit and was abandoned, so the form's + /// scripts no longer run in this document (reopening it runs them again). The script's + /// error is in the JavaScript output; this is the state, for a viewer to show. + pub fn xfa_scripts_off(&self) -> bool { + self.xfa_scripts_off + } + + /// PDF Optimizer ▸ Audit space usage. + pub fn audit_space(&self) -> Vec { + self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() + } + + /// Edit a PDF: the images `page` draws (0-based). + pub fn page_images(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() + } + + /// Mixed editable objects on one page. Source inventories are zero-based and added items + /// appear once. Rectangles are user-space points, independent of the viewer's zoom. + pub fn editable_objects(&self, page: usize) -> Result, String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string()) + } + + /// Displayed top-left-origin motion to a user-space vector. Use the linear map only, + /// so even a large crop-box origin cannot erase a small drag by cancellation. + pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> { + let p = self.info.pages.get(page).ok_or("the page no longer exists")?; + let [x0, y0, x1, y1] = p.crop.map(f64::from); + let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3))); + let (u, v) = match p.rotation { + 90 => (b, -a), + 180 => (-a, -b), + 270 => (-b, a), + _ => (a, b), + }; + let offset = [u * (x1 - x0), -v * (y1 - y0)]; + if !offset.iter().all(|v| v.is_finite()) { + return Err("the move must be finite and non-zero".into()); + } + Ok(offset) + } + + /// Save image as: image `index` on `page` as a file (extension, bytes). + pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { + self.check_copying().map_err(|e| e.to_string())?; + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?; + if img.is_form { + return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into()); + } + pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) + } + + /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). + pub fn text_blocks(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default() + } + + /// Saved measurement annotations, calculated from their geometry and PDF scales, plus + /// the ones that couldn't be read (unsupported formats are skipped, not fatal). + pub fn measurements(&self) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + Ok(measure::list(&e.cos)) + } + pub fn measurement_scale(&self, page: usize, at: measure::Point) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string()) + } + /// [`Self::measurement_scale`], measuring true size in `unit` (`per_point` of them in a + /// point) where the page sets no scale. + pub fn measurement_scale_or(&self, page: usize, at: measure::Point, per_point: f64, unit: &str) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::scale_at_or(&e.cos, page, at, per_point, unit).map_err(|e| e.to_string()) + } + pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string()) + } + pub fn measurement_to_view(&self, page: usize, point: measure::Point) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::user_to_view(&e.cos, page, point).map_err(|e| e.to_string()) + } + pub fn measurement_paths(&self, page: usize) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string()) + } + + /// A counter that changes with every edit (for caches of derived data). + pub fn edit_generation(&self) -> u64 { + self.generation + } + + /// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based). + pub fn text_lines(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default() + } + + /// The form's calculation order: its calculated fields in the order they calculate, what each + /// formula reads, what is wrong with the order, and the order Fix order writes. + pub fn calculation_order(&self) -> CalculationOrder { + self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default() + } + + /// Add alternate text: the figures, in document order. + pub fn figures(&self) -> Vec { + self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() + } + + /// Reachable indirect structure tags, in document order. + pub fn tags(&self) -> Vec { + self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default() + } + + /// Marked-content items directly owned by one reachable indirect structure tag. + pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result, a11y::TagError> { + self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new())) + } + + /// The edit that fixes `rule`, for the rules with an automatic fix: the document language + /// (`value` is the language), the title (`value` replaces it; else the current title or the + /// file name) and the tab order. + pub fn accessibility_fix(&self, rule: a11y::Rule, value: Option<&str>) -> Result { + let value = value.map(str::trim).filter(|v| !v.is_empty()); + match rule { + a11y::Rule::PrimaryLanguage => { + let mut v = self.initial_view(); + v.language = Some(value.ok_or("give the document language (for example en-US)")?.to_owned()); + Ok(Edit::SetInitialView(Box::new(v))) + } + a11y::Rule::Title => { + let title = value + .map(str::to_owned) + .or_else(|| self.info_value("Title").filter(|t| !t.trim().is_empty())) + .unwrap_or_else(|| self.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_owned()); + let mut v = self.initial_view(); + v.display_title = true; + Ok(Edit::Batch { + label: "Set document title".into(), + edits: vec![Edit::SetInfo { key: "Title".into(), value: title }, Edit::SetInitialView(Box::new(v))], + }) + } + a11y::Rule::TabOrder => Ok(Edit::SetTabOrder { pages: (0..self.info.pages.len()).collect(), order: TabOrder::Structure }), + other => Err(format!("\"{}\" has no automatic fix", other.name())), + } + } + + pub fn initial_view(&self) -> InitialView { + self.editor.as_ref().map(|e| pdfcraft_organize::initial_view(&e.cos)).unwrap_or_default() + } + + /// Signed: at least one signature field holds a signature. + pub fn is_signed(&self) -> bool { + self.signatures.iter().any(|s| s.signed) + } + + /// Whether comments are hidden (Comments ▸ Hide all comments). + pub fn comments_hidden(&self) -> bool { + self.config.hide_comments + } + + pub fn can_undo(&self) -> Option<&str> { + self.undo_label().map(Label::as_str) + } + + pub fn can_redo(&self) -> Option<&str> { + self.redo_label().map(Label::as_str) + } + + /// What Undo would undo, in parts, for an interface that says it in another language. + pub fn undo_label(&self) -> Option<&Label> { + self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l) + } + + /// What Redo would redo, in parts. + pub fn redo_label(&self) -> Option<&Label> { + self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l) + } + + pub fn editable(&self) -> bool { + self.editor.is_some() + } + + /// What the opening password allows; `None` when the document is not encrypted. + pub fn permissions(&self) -> Option { + self.editor.as_ref().and_then(|e| e.cos.permissions()) + } + + /// Printing is allowed (Table 22, bit 3). + pub fn allows_printing(&self) -> bool { + self.permissions().is_none_or(|p| p.print()) + } + + /// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying + /// selected text, snapshots, Save Image As, exporting. Assistive technology may read the + /// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask. + pub fn allows_copying(&self) -> bool { + self.permissions().is_none_or(|p| p.copy()) + } + + /// [`Self::allows_copying`], as the error to report when it isn't. + pub fn check_copying(&self) -> Result<(), EditError> { + if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) } + } + + /// Page changes (insert, delete, rotate, move, extract) are allowed. + pub fn allows_assembly(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.assemble()) + } + + /// Redaction marks waiting to be applied. + pub fn redaction_marks(&self) -> usize { + self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() + } + + /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. + pub fn marks(&self) -> &[MarkKind] { + &self + .marker_scan + .get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_marker_scan(); + pdfcraft_edit::scan_markers(&e.cos) + }) + .unwrap_or_default() + }) + .marks + } + + /// Text and images added with Edit a PDF ▸ Add content (still editable). + pub fn added(&self) -> &[pdfcraft_edit::Added] { + &self + .marker_scan + .get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_marker_scan(); + pdfcraft_edit::scan_markers(&e.cos) + }) + .unwrap_or_default() + }) + .added + } + + /// Link annotations, for Edit a PDF ▸ Link. + pub fn links(&self) -> &[pdfcraft_annot::links::LinkItem] { + self.links.get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_link_scan(); + pdfcraft_annot::links::list(&e.cos) + }) + .unwrap_or_default() + }) + } + + /// Changes to content and document information are allowed. + pub fn allows_modification(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.modify()) + } + + /// How widget `widget` of text field `name` lays its value out, for asking whether a typed + /// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no + /// more than fits. `None` without such a field or widget, or when the document can't be + /// edited. + pub fn field_text_box(&self, name: &str, widget: usize) -> Option { + let cos = &self.editor.as_ref()?.cos; + let f = self.form.iter().find(|f| f.name == name)?; + let w = f.widgets.get(widget)?; + Some(form_appearance::text_box(cos, f, w)) + } + + /// Filling in form fields is allowed (Table 22, bits 6 and 9). + pub fn allows_form_filling(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.fill_forms()) + } + + /// Adding and changing comments is allowed (Table 22, bit 6). + pub fn allows_annotation(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.annotate()) + } + + /// The document's security may be changed (Protect, Remove security): it is editable and, + /// if encrypted, was opened with the owner password. + pub fn allows_security_change(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| unrestricted(&p)) + } + + /// A password restricts the document and it wasn't opened with the owner password: entering + /// that password ([`Session::unlock`]) lifts the restrictions. + pub fn can_unlock(&self) -> bool { + self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p)) + } + + /// A summary of the document's security for Document Properties ▸ Security, including + /// protection applied in this session (written by the next save). + pub fn security_summary(&self) -> Option { + let editor = self.editor.as_ref()?; + let h = editor.cos.output_handler()?; + let d = h.dict(); + let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m); + let method = match (d.v, stream) { + (1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit", + (1..=3, _) | (_, Some(pdfcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit", + (_, Some(pdfcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit", + (_, Some(pdfcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit", + _ => "Attachments only", + }; + let pending = editor.cos.encryption_changed(); + let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() }; + Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending }) + } + + /// Comment properties of the comment at `(page, index)` (Comment Properties dialog). + pub fn comment_props(&self, page: usize, index: usize) -> Option { + self.editor.as_ref().and_then(|e| pdfcraft_annot::props(&e.cos, page, index)) + } + + /// A form field's Appearance-tab look (borders, colours, font). + pub fn field_look(&self, name: &str) -> Option { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + Some(pdfcraft_forms::look(&e.cos, f)) + } + + /// A check box's or radio button's mark (Field Properties ▸ Options), `None` for other fields. + pub fn field_check_style(&self, name: &str) -> Option { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f)) + } + + /// Whether text field `name`'s value is cut off by its box at the field's font size (#4018): + /// the page (0-based) of the first widget that cuts it, and how. + pub fn field_overflow(&self, name: &str) -> Option<(Option, pdfcraft_forms::appearance::Overflow)> { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o))) + } + + /// Remove Hidden Information: what each category would remove. + pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { + self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() + } + + /// Verification: faux redactions (opaque covers over extractable text) and redaction + /// marks that were never applied. Fails closed: any read error is returned, never an + /// empty list. + pub fn audit_redactions(&self) -> Result, String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_audit::audit_redactions(&editor.cos).map_err(|e| e.to_string()) + } + + /// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes. + pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> { + self.editor.as_ref().and_then(|e| pdfcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default() + } + + /// Current value of a document-information entry (Title, Author, …). + pub fn info_value(&self, key: &str) -> Option { + self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) + } + + /// Whether the document information has an entry `key`, of any type, listed or not. + pub fn has_info(&self, key: &str) -> bool { + self.editor.as_ref().is_some_and(|e| pdfcraft_organize::has_info(&e.cos, key)) + } + + /// The custom document properties as they are now (name, text), Document Properties ▸ Custom. + pub fn custom_info(&self) -> Vec<(String, String)> { + self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default() + } + + /// The name shown on the tab and window: the document title when the document asks for it + /// (Initial View ▸ Show: Document Title) and has a real one, else the file name. + pub fn display_name(&self) -> String { + self.editor + .as_ref() + .filter(|e| pdfcraft_organize::displays_doc_title(&e.cos)) + .and_then(|e| pdfcraft_organize::info(&e.cos, "Title")) + .map(|t| t.trim().to_owned()) + .filter(|t| !t.is_empty() && !is_placeholder_title(t)) + .unwrap_or_else(|| self.name.clone()) + } + + /// Where each saved revision of the file ends (oldest first; `bytes[..end]` is that + /// revision). Unsaved edits aren't a revision yet. + pub fn revision_ends(&self) -> Vec { + self.editor.as_ref().map(|e| e.cos.revision_ends()).unwrap_or_default() + } + + /// The file as last opened or saved, without unsaved edits: what an incremental save appends + /// to. This stays that file when undo has restored an older snapshot. + pub fn saved_bytes(&self) -> Arc> { + self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone()) + } + + /// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices). + pub fn repair_log(&self) -> Vec { + self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default() + } +} + +/// A title that names no document: what a web browser writes when it saves a blank page or a +/// pop-up as PDF (`about:blank`), another browser-internal address, or a bare "Untitled". Such a +/// title says less than the file name, so the file name is shown instead. An address is one +/// word: "About: our company" is a real title. +pub(crate) fn is_placeholder_title(title: &str) -> bool { + let t = title.trim().to_lowercase(); + let address = !t.contains(char::is_whitespace) && ["about:", "blob:", "data:"].iter().any(|p| t.starts_with(p)); + address || t == "untitled" +} + +/// The working file plus appearances for comments that have none, and for form fields the file +/// leaves to the viewer (#3470). Signature artwork is preserved; validation badges belong to the +/// viewer and never enter these bytes, which image/text exports and Print as Image also consume. +pub(crate) fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc> { + let Some(e) = editor else { return bytes.clone() }; + let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances); + let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances); + let Some(display) = fields.or(comments) else { + return bytes.clone(); + }; + match write_incremental(&display, &SaveOptions::default()) { + Ok(written) => Arc::new(written), + Err(err) => { + e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})")); + bytes.clone() + } + } +} + +/// `comments` (the display copy with comment appearances, if it needed any; else the document +/// itself) with its form fields drawn from their values where the file leaves that to the viewer. +fn with_field_appearances(comments: Option, doc: &pdfcraft_cos::Document) -> Option { + pdfcraft_forms::with_field_appearances(comments.as_ref().unwrap_or(doc)).or(comments) +} + +/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation. +pub(crate) fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool { + a.len() == b.len() + && a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation) +} + +/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read +/// the way the full inspection reads them: NULs and spaces trimmed, empty is none. +pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 6] { + let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); + [ + (&mut info.title, read("Title")), + (&mut info.author, read("Author")), + (&mut info.subject, read("Subject")), + (&mut info.keywords, read("Keywords")), + (&mut info.creator, read("Creator")), + (&mut info.producer, read("Producer")), + ] +} + +/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for +/// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page +/// images agree with the app and independent viewers. It never changes the source document, and an +/// unparseable file, a write failure or a parser panic falls back to the original bytes. +pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc> { + guard(|| { + pdfcraft_cos::Document::open_with_password(bytes.clone(), password) + .ok() + .and_then(|cos| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos)) + .and_then(|display| write_incremental(&display, &SaveOptions::default()).ok()) + .map(Arc::new) + }) + .ok() + .flatten() + .unwrap_or(bytes) +} + +/// The password to give the renderer (`inspect`, `PageRenderer`) for `bytes` opened with +/// `password`. The renderer authenticates only the user password of R2–R4 files, so for their +/// owner password this is the user password that owner authentication recovers; otherwise it is +/// `password` itself. Render paths outside a [`Session`], such as the CLI, use this so the owner +/// password works there as it does in `doc_open`. A file that doesn't open with `password` keeps +/// it, so the renderer reports the same error it always did. +pub fn render_password_for(bytes: Arc>, password: Option<&str>) -> Option { + let password = password?; + let recovered = + guard(|| pdfcraft_cos::Document::open_with_password(bytes, Some(password)).ok().and_then(|d| recovered_render_password(&d))).ok().flatten(); + Some(recovered.unwrap_or_else(|| password.to_owned())) +} + +/// The user password owner authentication recovered from an R2–R4 `cos`, as the renderer takes it. +pub(crate) fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { + cos.security().and_then(|s| s.recovered_user_password()).map(|user| renderer_password(&user)) +} + +/// Fails when the renderer still draws text under an applied redaction mark. +/// +/// Redaction decides what lies under a mark from the positions its own interpreter computes, and +/// its verify pass reuses them. Where they differ from the renderer's (a standard font without +/// /Widths, whose advances the interpreter approximates; a CMap +/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the +/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is +/// what the user saw when placing the marks, so it is asked. `after` is written as a save would +/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its +/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps +/// the document as it was. +pub(crate) fn shown_under_redactions( + after: &pdfcraft_cos::Document, + marks: &[pdfcraft_redact::Mark], + overlays: &[pdfcraft_cos::ObjRef], + pages: &[pdfcraft_render::PageInfo], + password: Option<&str>, +) -> Result<(), EditError> { + use pdfcraft_cos::Object; + use pdfcraft_redact::RedactError; + let mut marked: Vec = marks.iter().map(|m| m.page).collect(); + marked.sort_unstable(); + marked.dedup(); + if marked.is_empty() { + return Ok(()); + } + let mut shown = after.clone(); + let model = pdfcraft_model::pages(&shown); + for &pi in &marked { + let Some(page) = model.get(pi) else { continue }; + let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; + let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect(); + shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; + } + let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; + let config = RenderConfig { password: password.map(Arc::from), ..Default::default() }; + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config); + let mut residue = 0; + for &pi in &marked { + let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }; + // A page that cannot be read back cannot be confirmed clean. + let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) }; + let rects: Vec<[f64; 4]> = marks + .iter() + .filter(|m| m.page == pi) + .flat_map(|m| m.rects.iter()) + .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]) + .collect(); + for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) { + let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from); + let area = (x1 - x0) * (y1 - y0); + if area.is_nan() || area <= 0.0 { + continue; + } + // Redaction itself takes any glyph a fifth under a mark; one that is half under it + // and still drawn was not where redaction thought it was. + let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area; + if rects.iter().any(under) { + residue += 1; + } + } + } + if residue > 0 { + return Err(RedactError::Residue(residue).into()); + } + Ok(()) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 91d22accd7..15f6629e07 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod commands; pub mod compare; mod copy_image; pub mod dates; +mod document; mod edit; mod error; pub mod export; @@ -135,7 +136,7 @@ pub fn merge_data_files(files: &[(String, Vec)]) -> Result { /// One file produced by a split: (1-based first page, last page, PDF bytes). pub type SplitPart = (usize, usize, Arc>); -use std::sync::{Arc, OnceLock}; +use std::sync::Arc; #[cfg(test)] thread_local! { @@ -185,14 +186,14 @@ fn record_link_scan() { }); } +pub use document::{Document, TextNotice, display_bytes_for, render_password_for}; +use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; use edit::EditCtx; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; -use history::Editor; pub use history::Label; pub use open::{OpenContext, PreparedDocument}; -use pdfcraft_cos::{SaveOptions, write_incremental}; -use pdfcraft_render::{DocInfo, Layer, LayerOp, RenderConfig, RenderPool}; +use pdfcraft_render::{Layer, LayerOp, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; @@ -205,705 +206,6 @@ pub struct DocId(pub u64); /// clear) is printed at. const LOW_RESOLUTION_PRINT_DPI: f64 = 150.0; -pub struct Document { - pub id: DocId, - pub name: String, - pub path: Option, - /// The working file: what Save writes. - pub bytes: Arc>, - /// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances - /// drawn for display only for comments that have none ([`display_bytes`]). Never saved. - pub display: Arc>, - pub info: DocInfo, - pub renderer: RenderPool, - /// The password the document was opened with (needed to read attachments, etc.). - pub password: Option, - /// `true` when there are edits that have not been saved. - pub dirty: bool, - /// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it. - generation: u64, - /// The generation last handed out by `autosave_snapshots`. - snapshot_generation: u64, - /// Why the document cannot be edited (e.g. encryption), if so. - pub read_only_reason: Option, - /// Interactive form fields of the current state (empty without a form). - pub form: Arc>, - marker_scan: OnceLock, - links: OnceLock>, - /// Signature fields, validated against the session's trust store (Signatures panel). - pub signatures: Arc>, - trust: Arc, - sig_cache: Arc, - editor: Option, - config: RenderConfig, - /// What field scripts printed or asked for (see [`Session::take_js_output`]). - js_output: js::JsOutput, - /// Dynamic XFA forms: what laying the template out produced (pages and fields are - /// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves). - pub xfa: Option, - /// The parsed template of a laid-out XFA form, for its scripts. - xfa_template: Option>, - /// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]). - xfa_scripts_off: bool, - /// A content-only refresh keeps `info` from before the edit; an edit may have added or - /// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]). - fonts_stale: bool, - /// XFA forms: what was approximated, rewritten or could not be written to the XFA data - /// (also in `info.warnings`, kept there when the document is re-read). - pub xfa_warnings: Vec, - /// What the last successful [`Session::apply`] wrote that its fonts can't draw: kept as typed, - /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. - pub edit_notices: Vec, -} - -/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn -/// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat -/// keeps such text too, drawing it with fonts PdfCraft can't embed yet. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TextNotice { - /// The form field's full name, or `None` for a comment. - pub field: Option, - /// The page (0-based) of the comment or of the field's widget. - pub page: usize, - pub ch: char, - /// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances` - /// set); otherwise the `?` shows in every viewer. - pub redrawn_elsewhere: bool, -} - -impl std::fmt::Display for TextNotice { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch)); - let page = self.page + 1; - match (&self.field, self.redrawn_elsewhere) { - (Some(name), true) => write!( - f, - "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value" - ), - (Some(name), false) => write!( - f, - "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere" - ), - (None, _) => write!( - f, - "a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer" - ), - } - } -} - -/// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying -/// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order. -fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { - let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() }; - let redrawn_elsewhere = pdfcraft_forms::need_appearances(next); - let mut names: Option> = None; - let mut out: Vec = Vec::new(); - for (page, p) in pages.iter().enumerate() { - let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a)); - for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) { - let now = next.get(r); - let Some(d) = now.as_dict() else { continue }; - let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue }; - // Only what this edit drew: a new annotation, or a new appearance. - let was = prev.get(r); - if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) { - continue; - } - let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| { - let names = names.get_or_insert_with(|| { - pdfcraft_forms::fields(next) - .into_iter() - .flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()) - .collect() - }); - names.get(&r).cloned().unwrap_or_default() - }); - if field.is_some() && out.iter().any(|n| n.field == field) { - continue; - } - out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch }); - } - } - out -} - -impl Document { - /// How the document opens (Document Properties ▸ Initial View). - /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be - /// read for editing). - pub fn accessibility_check(&self, options: &a11y::Options) -> Option { - self.editor.as_ref().map(|e| a11y::check(&e.cos, options)) - } - - /// A script of this XFA form ran past its time limit and was abandoned, so the form's - /// scripts no longer run in this document (reopening it runs them again). The script's - /// error is in the JavaScript output; this is the state, for a viewer to show. - pub fn xfa_scripts_off(&self) -> bool { - self.xfa_scripts_off - } - - /// PDF Optimizer ▸ Audit space usage. - pub fn audit_space(&self) -> Vec { - self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() - } - - /// Edit a PDF: the images `page` draws (0-based). - pub fn page_images(&self, page: usize) -> Vec { - self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() - } - - /// Mixed editable objects on one page. Source inventories are zero-based and added items - /// appear once. Rectangles are user-space points, independent of the viewer's zoom. - pub fn editable_objects(&self, page: usize) -> Result, String> { - let editor = self.editor.as_ref().ok_or("the document can't be read")?; - pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string()) - } - - /// Displayed top-left-origin motion to a user-space vector. Use the linear map only, - /// so even a large crop-box origin cannot erase a small drag by cancellation. - pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> { - let p = self.info.pages.get(page).ok_or("the page no longer exists")?; - let [x0, y0, x1, y1] = p.crop.map(f64::from); - let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3))); - let (u, v) = match p.rotation { - 90 => (b, -a), - 180 => (-a, -b), - 270 => (-b, a), - _ => (a, b), - }; - let offset = [u * (x1 - x0), -v * (y1 - y0)]; - if !offset.iter().all(|v| v.is_finite()) { - return Err("the move must be finite and non-zero".into()); - } - Ok(offset) - } - - /// Save image as: image `index` on `page` as a file (extension, bytes). - pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { - self.check_copying().map_err(|e| e.to_string())?; - let editor = self.editor.as_ref().ok_or("the document can't be read")?; - let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?; - if img.is_form { - return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into()); - } - pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) - } - - /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). - pub fn text_blocks(&self, page: usize) -> Vec { - self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default() - } - - /// Saved measurement annotations, calculated from their geometry and PDF scales, plus - /// the ones that couldn't be read (unsupported formats are skipped, not fatal). - pub fn measurements(&self) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - Ok(measure::list(&e.cos)) - } - pub fn measurement_scale(&self, page: usize, at: measure::Point) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string()) - } - /// [`Self::measurement_scale`], measuring true size in `unit` (`per_point` of them in a - /// point) where the page sets no scale. - pub fn measurement_scale_or(&self, page: usize, at: measure::Point, per_point: f64, unit: &str) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::scale_at_or(&e.cos, page, at, per_point, unit).map_err(|e| e.to_string()) - } - pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string()) - } - pub fn measurement_to_view(&self, page: usize, point: measure::Point) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::user_to_view(&e.cos, page, point).map_err(|e| e.to_string()) - } - pub fn measurement_paths(&self, page: usize) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string()) - } - - /// A counter that changes with every edit (for caches of derived data). - pub fn edit_generation(&self) -> u64 { - self.generation - } - - /// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based). - pub fn text_lines(&self, page: usize) -> Vec { - self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default() - } - - /// The form's calculation order: its calculated fields in the order they calculate, what each - /// formula reads, what is wrong with the order, and the order Fix order writes. - pub fn calculation_order(&self) -> CalculationOrder { - self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default() - } - - /// Add alternate text: the figures, in document order. - pub fn figures(&self) -> Vec { - self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() - } - - /// Reachable indirect structure tags, in document order. - pub fn tags(&self) -> Vec { - self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default() - } - - /// Marked-content items directly owned by one reachable indirect structure tag. - pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result, a11y::TagError> { - self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new())) - } - - /// The edit that fixes `rule`, for the rules with an automatic fix: the document language - /// (`value` is the language), the title (`value` replaces it; else the current title or the - /// file name) and the tab order. - pub fn accessibility_fix(&self, rule: a11y::Rule, value: Option<&str>) -> Result { - let value = value.map(str::trim).filter(|v| !v.is_empty()); - match rule { - a11y::Rule::PrimaryLanguage => { - let mut v = self.initial_view(); - v.language = Some(value.ok_or("give the document language (for example en-US)")?.to_owned()); - Ok(Edit::SetInitialView(Box::new(v))) - } - a11y::Rule::Title => { - let title = value - .map(str::to_owned) - .or_else(|| self.info_value("Title").filter(|t| !t.trim().is_empty())) - .unwrap_or_else(|| self.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_owned()); - let mut v = self.initial_view(); - v.display_title = true; - Ok(Edit::Batch { - label: "Set document title".into(), - edits: vec![Edit::SetInfo { key: "Title".into(), value: title }, Edit::SetInitialView(Box::new(v))], - }) - } - a11y::Rule::TabOrder => Ok(Edit::SetTabOrder { pages: (0..self.info.pages.len()).collect(), order: TabOrder::Structure }), - other => Err(format!("\"{}\" has no automatic fix", other.name())), - } - } - - pub fn initial_view(&self) -> InitialView { - self.editor.as_ref().map(|e| pdfcraft_organize::initial_view(&e.cos)).unwrap_or_default() - } - - /// Signed: at least one signature field holds a signature. - pub fn is_signed(&self) -> bool { - self.signatures.iter().any(|s| s.signed) - } - - /// Whether comments are hidden (Comments ▸ Hide all comments). - pub fn comments_hidden(&self) -> bool { - self.config.hide_comments - } - - pub fn can_undo(&self) -> Option<&str> { - self.undo_label().map(Label::as_str) - } - - pub fn can_redo(&self) -> Option<&str> { - self.redo_label().map(Label::as_str) - } - - /// What Undo would undo, in parts, for an interface that says it in another language. - pub fn undo_label(&self) -> Option<&Label> { - self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l) - } - - /// What Redo would redo, in parts. - pub fn redo_label(&self) -> Option<&Label> { - self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l) - } - - pub fn editable(&self) -> bool { - self.editor.is_some() - } - - /// What the opening password allows; `None` when the document is not encrypted. - pub fn permissions(&self) -> Option { - self.editor.as_ref().and_then(|e| e.cos.permissions()) - } - - /// Printing is allowed (Table 22, bit 3). - pub fn allows_printing(&self) -> bool { - self.permissions().is_none_or(|p| p.print()) - } - - /// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying - /// selected text, snapshots, Save Image As, exporting. Assistive technology may read the - /// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask. - pub fn allows_copying(&self) -> bool { - self.permissions().is_none_or(|p| p.copy()) - } - - /// [`Self::allows_copying`], as the error to report when it isn't. - pub fn check_copying(&self) -> Result<(), EditError> { - if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) } - } - - /// Page changes (insert, delete, rotate, move, extract) are allowed. - pub fn allows_assembly(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.assemble()) - } - - /// Redaction marks waiting to be applied. - pub fn redaction_marks(&self) -> usize { - self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() - } - - /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. - pub fn marks(&self) -> &[MarkKind] { - &self - .marker_scan - .get_or_init(|| { - self.editor - .as_ref() - .map(|e| { - #[cfg(test)] - record_marker_scan(); - pdfcraft_edit::scan_markers(&e.cos) - }) - .unwrap_or_default() - }) - .marks - } - - /// Text and images added with Edit a PDF ▸ Add content (still editable). - pub fn added(&self) -> &[pdfcraft_edit::Added] { - &self - .marker_scan - .get_or_init(|| { - self.editor - .as_ref() - .map(|e| { - #[cfg(test)] - record_marker_scan(); - pdfcraft_edit::scan_markers(&e.cos) - }) - .unwrap_or_default() - }) - .added - } - - /// Link annotations, for Edit a PDF ▸ Link. - pub fn links(&self) -> &[pdfcraft_annot::links::LinkItem] { - self.links.get_or_init(|| { - self.editor - .as_ref() - .map(|e| { - #[cfg(test)] - record_link_scan(); - pdfcraft_annot::links::list(&e.cos) - }) - .unwrap_or_default() - }) - } - - /// Changes to content and document information are allowed. - pub fn allows_modification(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.modify()) - } - - /// How widget `widget` of text field `name` lays its value out, for asking whether a typed - /// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no - /// more than fits. `None` without such a field or widget, or when the document can't be - /// edited. - pub fn field_text_box(&self, name: &str, widget: usize) -> Option { - let cos = &self.editor.as_ref()?.cos; - let f = self.form.iter().find(|f| f.name == name)?; - let w = f.widgets.get(widget)?; - Some(form_appearance::text_box(cos, f, w)) - } - - /// Filling in form fields is allowed (Table 22, bits 6 and 9). - pub fn allows_form_filling(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.fill_forms()) - } - - /// Adding and changing comments is allowed (Table 22, bit 6). - pub fn allows_annotation(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.annotate()) - } - - /// The document's security may be changed (Protect, Remove security): it is editable and, - /// if encrypted, was opened with the owner password. - pub fn allows_security_change(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| unrestricted(&p)) - } - - /// A password restricts the document and it wasn't opened with the owner password: entering - /// that password ([`Session::unlock`]) lifts the restrictions. - pub fn can_unlock(&self) -> bool { - self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p)) - } - - /// A summary of the document's security for Document Properties ▸ Security, including - /// protection applied in this session (written by the next save). - pub fn security_summary(&self) -> Option { - let editor = self.editor.as_ref()?; - let h = editor.cos.output_handler()?; - let d = h.dict(); - let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m); - let method = match (d.v, stream) { - (1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit", - (1..=3, _) | (_, Some(pdfcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit", - (_, Some(pdfcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit", - (_, Some(pdfcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit", - _ => "Attachments only", - }; - let pending = editor.cos.encryption_changed(); - let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() }; - Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending }) - } - - /// Comment properties of the comment at `(page, index)` (Comment Properties dialog). - pub fn comment_props(&self, page: usize, index: usize) -> Option { - self.editor.as_ref().and_then(|e| pdfcraft_annot::props(&e.cos, page, index)) - } - - /// A form field's Appearance-tab look (borders, colours, font). - pub fn field_look(&self, name: &str) -> Option { - let e = self.editor.as_ref()?; - let f = self.form.iter().find(|f| f.name == name)?; - Some(pdfcraft_forms::look(&e.cos, f)) - } - - /// A check box's or radio button's mark (Field Properties ▸ Options), `None` for other fields. - pub fn field_check_style(&self, name: &str) -> Option { - let e = self.editor.as_ref()?; - let f = self.form.iter().find(|f| f.name == name)?; - matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f)) - } - - /// Whether text field `name`'s value is cut off by its box at the field's font size (#4018): - /// the page (0-based) of the first widget that cuts it, and how. - pub fn field_overflow(&self, name: &str) -> Option<(Option, pdfcraft_forms::appearance::Overflow)> { - let e = self.editor.as_ref()?; - let f = self.form.iter().find(|f| f.name == name)?; - f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o))) - } - - /// Remove Hidden Information: what each category would remove. - pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { - self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() - } - - /// Verification: faux redactions (opaque covers over extractable text) and redaction - /// marks that were never applied. Fails closed: any read error is returned, never an - /// empty list. - pub fn audit_redactions(&self) -> Result, String> { - let editor = self.editor.as_ref().ok_or("the document can't be read")?; - pdfcraft_audit::audit_redactions(&editor.cos).map_err(|e| e.to_string()) - } - - /// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes. - pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> { - self.editor.as_ref().and_then(|e| pdfcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default() - } - - /// Current value of a document-information entry (Title, Author, …). - pub fn info_value(&self, key: &str) -> Option { - self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) - } - - /// Whether the document information has an entry `key`, of any type, listed or not. - pub fn has_info(&self, key: &str) -> bool { - self.editor.as_ref().is_some_and(|e| pdfcraft_organize::has_info(&e.cos, key)) - } - - /// The custom document properties as they are now (name, text), Document Properties ▸ Custom. - pub fn custom_info(&self) -> Vec<(String, String)> { - self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default() - } - - /// The name shown on the tab and window: the document title when the document asks for it - /// (Initial View ▸ Show: Document Title) and has a real one, else the file name. - pub fn display_name(&self) -> String { - self.editor - .as_ref() - .filter(|e| pdfcraft_organize::displays_doc_title(&e.cos)) - .and_then(|e| pdfcraft_organize::info(&e.cos, "Title")) - .map(|t| t.trim().to_owned()) - .filter(|t| !t.is_empty() && !is_placeholder_title(t)) - .unwrap_or_else(|| self.name.clone()) - } - - /// Where each saved revision of the file ends (oldest first; `bytes[..end]` is that - /// revision). Unsaved edits aren't a revision yet. - pub fn revision_ends(&self) -> Vec { - self.editor.as_ref().map(|e| e.cos.revision_ends()).unwrap_or_default() - } - - /// The file as last opened or saved, without unsaved edits: what an incremental save appends - /// to. This stays that file when undo has restored an older snapshot. - pub fn saved_bytes(&self) -> Arc> { - self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone()) - } - - /// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices). - pub fn repair_log(&self) -> Vec { - self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default() - } -} - -/// A title that names no document: what a web browser writes when it saves a blank page or a -/// pop-up as PDF (`about:blank`), another browser-internal address, or a bare "Untitled". Such a -/// title says less than the file name, so the file name is shown instead. An address is one -/// word: "About: our company" is a real title. -fn is_placeholder_title(title: &str) -> bool { - let t = title.trim().to_lowercase(); - let address = !t.contains(char::is_whitespace) && ["about:", "blob:", "data:"].iter().any(|p| t.starts_with(p)); - address || t == "untitled" -} - -/// The working file plus appearances for comments that have none, and for form fields the file -/// leaves to the viewer (#3470). Signature artwork is preserved; validation badges belong to the -/// viewer and never enter these bytes, which image/text exports and Print as Image also consume. -fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc> { - let Some(e) = editor else { return bytes.clone() }; - let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances); - let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances); - let Some(display) = fields.or(comments) else { - return bytes.clone(); - }; - match write_incremental(&display, &SaveOptions::default()) { - Ok(written) => Arc::new(written), - Err(err) => { - e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})")); - bytes.clone() - } - } -} - -/// `comments` (the display copy with comment appearances, if it needed any; else the document -/// itself) with its form fields drawn from their values where the file leaves that to the viewer. -fn with_field_appearances(comments: Option, doc: &pdfcraft_cos::Document) -> Option { - pdfcraft_forms::with_field_appearances(comments.as_ref().unwrap_or(doc)).or(comments) -} - -/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation. -fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool { - a.len() == b.len() - && a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation) -} - -/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read -/// the way the full inspection reads them: NULs and spaces trimmed, empty is none. -fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 6] { - let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); - [ - (&mut info.title, read("Title")), - (&mut info.author, read("Author")), - (&mut info.subject, read("Subject")), - (&mut info.keywords, read("Keywords")), - (&mut info.creator, read("Creator")), - (&mut info.producer, read("Producer")), - ] -} - -/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for -/// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page -/// images agree with the app and independent viewers. It never changes the source document, and an -/// unparseable file, a write failure or a parser panic falls back to the original bytes. -pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc> { - guard(|| { - pdfcraft_cos::Document::open_with_password(bytes.clone(), password) - .ok() - .and_then(|cos| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos)) - .and_then(|display| write_incremental(&display, &SaveOptions::default()).ok()) - .map(Arc::new) - }) - .ok() - .flatten() - .unwrap_or(bytes) -} - -/// The password to give the renderer (`inspect`, `PageRenderer`) for `bytes` opened with -/// `password`. The renderer authenticates only the user password of R2–R4 files, so for their -/// owner password this is the user password that owner authentication recovers; otherwise it is -/// `password` itself. Render paths outside a [`Session`], such as the CLI, use this so the owner -/// password works there as it does in `doc_open`. A file that doesn't open with `password` keeps -/// it, so the renderer reports the same error it always did. -pub fn render_password_for(bytes: Arc>, password: Option<&str>) -> Option { - let password = password?; - let recovered = - guard(|| pdfcraft_cos::Document::open_with_password(bytes, Some(password)).ok().and_then(|d| recovered_render_password(&d))).ok().flatten(); - Some(recovered.unwrap_or_else(|| password.to_owned())) -} - -/// The user password owner authentication recovered from an R2–R4 `cos`, as the renderer takes it. -fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { - cos.security().and_then(|s| s.recovered_user_password()).map(|user| renderer_password(&user)) -} - -/// Fails when the renderer still draws text under an applied redaction mark. -/// -/// Redaction decides what lies under a mark from the positions its own interpreter computes, and -/// its verify pass reuses them. Where they differ from the renderer's (a standard font without -/// /Widths, whose advances the interpreter approximates; a CMap -/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the -/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is -/// what the user saw when placing the marks, so it is asked. `after` is written as a save would -/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its -/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps -/// the document as it was. -fn shown_under_redactions( - after: &pdfcraft_cos::Document, - marks: &[pdfcraft_redact::Mark], - overlays: &[pdfcraft_cos::ObjRef], - pages: &[pdfcraft_render::PageInfo], - password: Option<&str>, -) -> Result<(), EditError> { - use pdfcraft_cos::Object; - use pdfcraft_redact::RedactError; - let mut marked: Vec = marks.iter().map(|m| m.page).collect(); - marked.sort_unstable(); - marked.dedup(); - if marked.is_empty() { - return Ok(()); - } - let mut shown = after.clone(); - let model = pdfcraft_model::pages(&shown); - for &pi in &marked { - let Some(page) = model.get(pi) else { continue }; - let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; - let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect(); - shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; - } - let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; - let config = RenderConfig { password: password.map(Arc::from), ..Default::default() }; - let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config); - let mut residue = 0; - for &pi in &marked { - let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }; - // A page that cannot be read back cannot be confirmed clean. - let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) }; - let rects: Vec<[f64; 4]> = marks - .iter() - .filter(|m| m.page == pi) - .flat_map(|m| m.rects.iter()) - .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]) - .collect(); - for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) { - let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from); - let area = (x1 - x0) * (y1 - y0); - if area.is_nan() || area <= 0.0 { - continue; - } - // Redaction itself takes any glyph a fifth under a mark; one that is half under it - // and still drawn was not where redaction thought it was. - let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area; - if rects.iter().any(under) { - residue += 1; - } - } - } - if residue > 0 { - return Err(RedactError::Residue(residue).into()); - } - Ok(()) -} - fn render_threads() -> usize { std::thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(4).clamp(2, 8) - 1 } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d06faefc8b..a30d81b74b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use super::document::is_placeholder_title; use super::edit::check_permission; use super::*; use pdfcraft_render::{OpenError, ReadingColors}; From 748b20abb91ee2ad8a5e9cdc23fdbc6aceb672ff Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:05:58 +0100 Subject: [PATCH 14/28] refactor(engine): move the XFA form helpers to xfa_form.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 299 +--------------------------------- crates/engine/src/xfa_form.rs | 297 +++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 294 deletions(-) create mode 100644 crates/engine/src/xfa_form.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 15f6629e07..56677ed5d3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -48,6 +48,7 @@ pub mod text_fonts; mod text_preview; pub mod word_count; pub mod xfa; +mod xfa_form; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; @@ -188,7 +189,6 @@ fn record_link_scan() { pub use document::{Document, TextNotice, display_bytes_for, render_password_for}; use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; -use edit::EditCtx; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; pub use history::Label; @@ -197,6 +197,10 @@ use pdfcraft_render::{Layer, LayerOp, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; +use xfa_form::{ + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state, + undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, +}; /// Stable identifier of an open document within a session. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -465,299 +469,6 @@ pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option pdfcraft_organize::page_count(&doc).ok() } -/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. -fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { - let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; - xfa_redraw(doc)?; - // The form's own values: one its field's font can't draw is drawn with `?` and reported. - report.warnings.extend(undrawn_field_warnings(doc)); - Ok(report) -} - -/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the -/// picture its signer's application drew. -pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> { - for f in pdfcraft_forms::fields(doc) { - if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { - continue; - } - pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; - } - Ok(()) -} - -/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font -/// can't draw (laying out an XFA form's own values). -fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec { - pdfcraft_forms::undrawn_fields(doc) - .into_iter() - .map(|(name, c)| { - format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c)) - }) - .collect() -} - -/// The form's fields as the XFA data layer wants them. -fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec { - use pdfcraft_forms::FieldKind as K; - pdfcraft_forms::fields(doc) - .into_iter() - .map(|f| { - let data = match f.kind { - K::Text | K::Combo | K::List => pdfcraft_xfa::FieldData::Text(f.value.join("\n")), - K::CheckBox => pdfcraft_xfa::FieldData::Check(!f.value.is_empty()), - K::Radio => pdfcraft_xfa::FieldData::Radio(f.value.first().cloned()), - K::PushButton | K::Signature => pdfcraft_xfa::FieldData::None, - }; - pdfcraft_xfa::FieldDatum { obj: f.obj, name: f.name, data } - }) - .collect() -} - -/// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not -/// be written. -/// `datasets`: the stream this edit already wrote, replaced in place rather than added again -/// (and set to the one written). -/// `tpl`: the form's parsed template when the document has one cached (else it is parsed). -fn xfa_sync_datasets( - doc: &mut pdfcraft_cos::Document, - tpl: Option<&pdfcraft_xfa::model::Template>, - datasets: &mut Option, -) -> Result, String> { - let data = xfa_field_data(doc); - let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?; - if r.stream.is_some() { - *datasets = r.stream; - } - Ok(r.warnings) -} - -/// What a document whose form script ran away is told. -const XFA_SCRIPTS_OFF: &str = - "A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)"; - -/// Most XFA warnings kept per document. -const MAX_XFA_WARNINGS: usize = 50; - -/// Add `new` to `list` (no repeats, at most [`MAX_XFA_WARNINGS`]). -fn note_warnings(list: &mut Vec, new: &[String]) { - for w in new { - if !list.contains(w) && list.len() < MAX_XFA_WARNINGS { - list.push(w.clone()); - } - } -} - -/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or -/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an -/// encoding this can't read. -pub(crate) fn decode_data_file(bytes: &[u8]) -> Option> { - if bytes.starts_with(b"%FDF") { - return None; - } - let declared = |text: &str| -> Option { - let head = text.get(..text.len().min(256))?; - let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("")?.0; - let enc = decl.split_once("encoding=")?.1; - let quote = enc.chars().next()?; - Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase()) - }; - if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) { - match declared(&text).as_deref() { - None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)), - Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => { - // Each byte is the character it names (the 0x80–0x9F block of Windows-1252 - // differs, and is rare in form data). - return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect())); - } - Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))), - } - } - // Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused. - let latin1: String = bytes.iter().map(|b| char::from(*b)).collect(); - match declared(&latin1).as_deref() { - Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)), - _ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())), - } -} - -/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, -/// buttons that did nothing but run scripts, and fields whose value differs from the form's -/// data (data PdfCraft could not read into them, now lost). -pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { - let fields = pdfcraft_forms::fields(doc); - let data = xfa_field_data(doc); - let differ = pdfcraft_xfa::read_values(doc, &data) - .into_iter() - .filter(|(name, value)| { - let Some(f) = fields.iter().find(|f| f.name == *name) else { return false }; - match value { - pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), - pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, - pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(), - pdfcraft_xfa::FieldData::None => false, - } - }) - .count(); - let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?; - cx.xfa_removed.extend(r.notes()); - if differ > 0 { - cx.xfa_removed.push(format!( - "{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone" - )); - } - Ok(()) -} - -/// Keep the document's XFA state in step with its file after an edit: a script may have laid -/// the form out again (rows added, subforms shown), an edit may have taken the XFA out -/// (flattened, or made static), or an undo put it back. -fn sync_xfa_state(doc: &mut Document) { - let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return }; - match pdfcraft_xfa::existing_layout(cos) { - Some(report) => { - if doc.xfa.is_none() { - // Back after an undo: the XFA's own warnings apply again, the strip's don't. - doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX)); - if doc.xfa_template.is_none() && !doc.xfa_scripts_off { - doc.xfa_template = xfa::template(cos); - } - } - doc.xfa = Some(report); - } - None => { - doc.xfa = None; - doc.xfa_template = None; - // Only what the strip said stays (the XFA's own warnings no longer apply). - doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); - } - } -} - -/// How the warnings of a strip start, so they can be told from the XFA's own. -const XFA_REMOVED_PREFIX: &str = "XFA removed: "; - -/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), -/// rather than XFDF, Acrobat's `` XML or a table? By its root element. -pub(crate) fn is_xfa_data_file(text: &str) -> bool { - let text = text.get(..text.len().min(65_536)).unwrap_or(text); - let mut rest = text.trim_start_matches('\u{feff}').trim_start(); - // Past the XML declaration, comments and processing instructions. - let mut guard = 0; - while guard < 100 { - guard += 1; - if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); - } else if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); - } else if let Some(r) = rest.strip_prefix("').map_or("", |(_, r)| r).trim_start(); - } else { - break; - } - } - let Some(tag) = rest.strip_prefix('<') else { return false }; - let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""); - let local = name.rsplit(':').next().unwrap_or(name); - !local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body") -} - -/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. -pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> { - match pdfcraft_xfa::image::kind(bytes)? { - pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), - pdfcraft_xfa::image::Kind::Png => Some("image/png"), - pdfcraft_xfa::image::Kind::Gif => Some("image/gif"), - pdfcraft_xfa::image::Kind::Bmp => Some("image/bmp"), - pdfcraft_xfa::image::Kind::Tiff => Some("image/tiff"), - } -} - -/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is -/// laid out again from the data (then, when `calculate`, its calculations run), and the -/// fields take the data's values. -pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { - if let Some(tpl) = cx.xfa.clone() { - let mut run = - xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; - let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) - .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - if let Err(e) = r { - cx.xfa_out.errors.push(e); - } - } - let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?; - if !taken.is_empty() { - cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len())); - } - // Not laid out again (a static form, or scripts off): fields the data no longer holds are - // emptied, so the data replaced is the data shown. - if cx.xfa.is_none() && calculate { - let data = xfa_field_data(doc); - let present: std::collections::HashSet = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect(); - for f in pdfcraft_forms::fields(doc) { - if present.contains(&f.name) || f.value.is_empty() { - continue; - } - use pdfcraft_forms::FieldKind as K; - let empty = match f.kind { - K::Text => FieldValue::Text(String::new()), - K::CheckBox => FieldValue::Check(false), - K::Radio => FieldValue::Radio(None), - K::Combo | K::List => FieldValue::Choice(Vec::new()), - K::PushButton | K::Signature => continue, - }; - if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) { - cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name)); - } - } - } - Ok(()) -} - -/// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns -/// the names of the fields that changed. -fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { - let data = xfa_field_data(doc); - let fields = pdfcraft_forms::fields(doc); - // The first field of each name, as a lookup by name would find it. - let mut by_name = std::collections::HashMap::new(); - for f in &fields { - by_name.entry(f.name.as_str()).or_insert(f); - } - let mut updates = Vec::new(); - for (name, value) in pdfcraft_xfa::read_values(doc, &data) { - let Some(f) = by_name.get(name.as_str()) else { continue }; - let new = match value { - pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => { - // Saved values, one per line for a multi-select list; shown text stands for - // its saved value (an older viewer may have written it). - let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone()); - let picked: Vec = if f.has(pdfcraft_forms::flags::MULTI_SELECT) { - t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect() - } else { - vec![saved(t.trim())] - }; - (f.value != picked).then_some(FieldValue::Choice(picked)) - } - pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)), - pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)), - pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)), - pdfcraft_xfa::FieldData::None => None, - }; - if let Some(v) = new { - updates.push((name, v)); - } - } - // All at once: one by one, each value would read the whole form again, which took minutes - // on a form with thousands of fields. - let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) }; - Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect()) -} - /// Validate the signature fields of `cos` (written as `bytes`). fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, cache: &pdfcraft_sign::DigestCache) -> Vec { pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) diff --git a/crates/engine/src/xfa_form.rs b/crates/engine/src/xfa_form.rs new file mode 100644 index 0000000000..5ceaba1cd4 --- /dev/null +++ b/crates/engine/src/xfa_form.rs @@ -0,0 +1,297 @@ +//! A document's XFA form: laying it out, keeping its datasets and fields in step, and taking it out. + +use crate::edit::EditCtx; +use crate::{Document, EditError, FieldValue, XfaLayout, guard, xfa}; + +/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. +pub(crate) fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { + let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; + xfa_redraw(doc)?; + // The form's own values: one its field's font can't draw is drawn with `?` and reported. + report.warnings.extend(undrawn_field_warnings(doc)); + Ok(report) +} + +/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the +/// picture its signer's application drew. +pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> { + for f in pdfcraft_forms::fields(doc) { + if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { + continue; + } + pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; + } + Ok(()) +} + +/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font +/// can't draw (laying out an XFA form's own values). +pub(crate) fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec { + pdfcraft_forms::undrawn_fields(doc) + .into_iter() + .map(|(name, c)| { + format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c)) + }) + .collect() +} + +/// The form's fields as the XFA data layer wants them. +fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec { + use pdfcraft_forms::FieldKind as K; + pdfcraft_forms::fields(doc) + .into_iter() + .map(|f| { + let data = match f.kind { + K::Text | K::Combo | K::List => pdfcraft_xfa::FieldData::Text(f.value.join("\n")), + K::CheckBox => pdfcraft_xfa::FieldData::Check(!f.value.is_empty()), + K::Radio => pdfcraft_xfa::FieldData::Radio(f.value.first().cloned()), + K::PushButton | K::Signature => pdfcraft_xfa::FieldData::None, + }; + pdfcraft_xfa::FieldDatum { obj: f.obj, name: f.name, data } + }) + .collect() +} + +/// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not +/// be written. +/// `datasets`: the stream this edit already wrote, replaced in place rather than added again +/// (and set to the one written). +/// `tpl`: the form's parsed template when the document has one cached (else it is parsed). +pub(crate) fn xfa_sync_datasets( + doc: &mut pdfcraft_cos::Document, + tpl: Option<&pdfcraft_xfa::model::Template>, + datasets: &mut Option, +) -> Result, String> { + let data = xfa_field_data(doc); + let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?; + if r.stream.is_some() { + *datasets = r.stream; + } + Ok(r.warnings) +} + +/// What a document whose form script ran away is told. +pub(crate) const XFA_SCRIPTS_OFF: &str = + "A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)"; + +/// Most XFA warnings kept per document. +const MAX_XFA_WARNINGS: usize = 50; + +/// Add `new` to `list` (no repeats, at most [`MAX_XFA_WARNINGS`]). +pub(crate) fn note_warnings(list: &mut Vec, new: &[String]) { + for w in new { + if !list.contains(w) && list.len() < MAX_XFA_WARNINGS { + list.push(w.clone()); + } + } +} + +/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or +/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an +/// encoding this can't read. +pub(crate) fn decode_data_file(bytes: &[u8]) -> Option> { + if bytes.starts_with(b"%FDF") { + return None; + } + let declared = |text: &str| -> Option { + let head = text.get(..text.len().min(256))?; + let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("")?.0; + let enc = decl.split_once("encoding=")?.1; + let quote = enc.chars().next()?; + Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase()) + }; + if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) { + match declared(&text).as_deref() { + None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)), + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => { + // Each byte is the character it names (the 0x80–0x9F block of Windows-1252 + // differs, and is rare in form data). + return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect())); + } + Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))), + } + } + // Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused. + let latin1: String = bytes.iter().map(|b| char::from(*b)).collect(); + match declared(&latin1).as_deref() { + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)), + _ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())), + } +} + +/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, +/// buttons that did nothing but run scripts, and fields whose value differs from the form's +/// data (data PdfCraft could not read into them, now lost). +pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { + let fields = pdfcraft_forms::fields(doc); + let data = xfa_field_data(doc); + let differ = pdfcraft_xfa::read_values(doc, &data) + .into_iter() + .filter(|(name, value)| { + let Some(f) = fields.iter().find(|f| f.name == *name) else { return false }; + match value { + pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), + pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, + pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(), + pdfcraft_xfa::FieldData::None => false, + } + }) + .count(); + let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_removed.extend(r.notes()); + if differ > 0 { + cx.xfa_removed.push(format!( + "{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone" + )); + } + Ok(()) +} + +/// Keep the document's XFA state in step with its file after an edit: a script may have laid +/// the form out again (rows added, subforms shown), an edit may have taken the XFA out +/// (flattened, or made static), or an undo put it back. +pub(crate) fn sync_xfa_state(doc: &mut Document) { + let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return }; + match pdfcraft_xfa::existing_layout(cos) { + Some(report) => { + if doc.xfa.is_none() { + // Back after an undo: the XFA's own warnings apply again, the strip's don't. + doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX)); + if doc.xfa_template.is_none() && !doc.xfa_scripts_off { + doc.xfa_template = xfa::template(cos); + } + } + doc.xfa = Some(report); + } + None => { + doc.xfa = None; + doc.xfa_template = None; + // Only what the strip said stays (the XFA's own warnings no longer apply). + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + } + } +} + +/// How the warnings of a strip start, so they can be told from the XFA's own. +pub(crate) const XFA_REMOVED_PREFIX: &str = "XFA removed: "; + +/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), +/// rather than XFDF, Acrobat's `` XML or a table? By its root element. +pub(crate) fn is_xfa_data_file(text: &str) -> bool { + let text = text.get(..text.len().min(65_536)).unwrap_or(text); + let mut rest = text.trim_start_matches('\u{feff}').trim_start(); + // Past the XML declaration, comments and processing instructions. + let mut guard = 0; + while guard < 100 { + guard += 1; + if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("').map_or("", |(_, r)| r).trim_start(); + } else { + break; + } + } + let Some(tag) = rest.strip_prefix('<') else { return false }; + let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""); + let local = name.rsplit(':').next().unwrap_or(name); + !local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body") +} + +/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. +pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> { + match pdfcraft_xfa::image::kind(bytes)? { + pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), + pdfcraft_xfa::image::Kind::Png => Some("image/png"), + pdfcraft_xfa::image::Kind::Gif => Some("image/gif"), + pdfcraft_xfa::image::Kind::Bmp => Some("image/bmp"), + pdfcraft_xfa::image::Kind::Tiff => Some("image/tiff"), + } +} + +/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is +/// laid out again from the data (then, when `calculate`, its calculations run), and the +/// fields take the data's values. +pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { + if let Some(tpl) = cx.xfa.clone() { + let mut run = + xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; + let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) + .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = r { + cx.xfa_out.errors.push(e); + } + } + let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?; + if !taken.is_empty() { + cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len())); + } + // Not laid out again (a static form, or scripts off): fields the data no longer holds are + // emptied, so the data replaced is the data shown. + if cx.xfa.is_none() && calculate { + let data = xfa_field_data(doc); + let present: std::collections::HashSet = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect(); + for f in pdfcraft_forms::fields(doc) { + if present.contains(&f.name) || f.value.is_empty() { + continue; + } + use pdfcraft_forms::FieldKind as K; + let empty = match f.kind { + K::Text => FieldValue::Text(String::new()), + K::CheckBox => FieldValue::Check(false), + K::Radio => FieldValue::Radio(None), + K::Combo | K::List => FieldValue::Choice(Vec::new()), + K::PushButton | K::Signature => continue, + }; + if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) { + cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name)); + } + } + } + Ok(()) +} + +/// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns +/// the names of the fields that changed. +pub(crate) fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { + let data = xfa_field_data(doc); + let fields = pdfcraft_forms::fields(doc); + // The first field of each name, as a lookup by name would find it. + let mut by_name = std::collections::HashMap::new(); + for f in &fields { + by_name.entry(f.name.as_str()).or_insert(f); + } + let mut updates = Vec::new(); + for (name, value) in pdfcraft_xfa::read_values(doc, &data) { + let Some(f) = by_name.get(name.as_str()) else { continue }; + let new = match value { + pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => { + // Saved values, one per line for a multi-select list; shown text stands for + // its saved value (an older viewer may have written it). + let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone()); + let picked: Vec = if f.has(pdfcraft_forms::flags::MULTI_SELECT) { + t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect() + } else { + vec![saved(t.trim())] + }; + (f.value != picked).then_some(FieldValue::Choice(picked)) + } + pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)), + pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)), + pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)), + pdfcraft_xfa::FieldData::None => None, + }; + if let Some(v) = new { + updates.push((name, v)); + } + } + // All at once: one by one, each value would read the whole form again, which took minutes + // on a form with thousands of fields. + let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) }; + Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect()) +} From 71dccdda505bf891c147d25ca985adcaadea0aa5 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:07:23 +0100 Subject: [PATCH 15/28] refactor(engine): move the comment list and summary to comments.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/comments.rs | 163 +++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 165 +--------------------------------- 2 files changed, 166 insertions(+), 162 deletions(-) create mode 100644 crates/engine/src/comments.rs diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs new file mode 100644 index 0000000000..eee799ed2f --- /dev/null +++ b/crates/engine/src/comments.rs @@ -0,0 +1,163 @@ +//! The comment list and its summary. + +/// The comment list, read from the object graph (as `inspect` lists comments). +pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { + let mut list: Vec = pdfcraft_annot::summaries(doc) + .into_iter() + .map(|s| pdfcraft_render::Annotation { + page: s.page, + subtype: s.subtype, + author: s.author, + contents: s.contents, + modified: s.modified.map(|m| pdfcraft_render::pretty_date(&m)), + name: s.name, + in_reply_to: s.in_reply_to, + parent: s.parent, + grouped: s.grouped, + rect: s.rect, + color: s.color, + index: s.index, + state: s.state, + quads: s.quads, + locked: s.locked, + intent: s.intent, + fill_sign: s.fill_sign, + no_rotate: s.no_rotate, + points: s.points, + }) + .collect(); + pdfcraft_render::link_replies(&mut list); + list +} + +/// Order of Summarize Comments. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SummarySort { + #[default] + Page, + Author, + Date, + Type, +} + +impl SummarySort { + pub const ALL: [SummarySort; 4] = [SummarySort::Page, SummarySort::Author, SummarySort::Date, SummarySort::Type]; + pub fn name(self) -> &'static str { + match self { + SummarySort::Page => "Page", + SummarySort::Author => "Author", + SummarySort::Date => "Date", + SummarySort::Type => "Type", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s)) + } +} + +/// Acrobat's name for a comment type, as comment lists show it. +pub fn comment_type_name(subtype: &str) -> &str { + match subtype { + "Text" => "Sticky Note", + "FreeText" => "Text Box", + "Highlight" => "Highlight", + "Underline" => "Underline", + "StrikeOut" => "Strikethrough", + "Squiggly" => "Squiggly", + "Square" => "Rectangle", + "Circle" => "Oval", + "Line" => "Line", + "Ink" => "Pencil", + "Polygon" => "Polygon", + "PolyLine" => "Polygonal Line", + "Stamp" => "Stamp", + "Caret" => "Inserted Text", + "FileAttachment" => "File Attachment", + "Sound" => "Sound", + "Redact" => "Redaction", + other => other, + } +} + +/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. +pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { + match a.intent.as_deref() { + Some("FreeTextCallout") => "Callout", + Some("PolygonCloud") => "Cloud", + Some("FreeTextTypeWriter") => "Typewriter", + _ => comment_type_name(&a.subtype), + } +} + +/// The text of a comment summary: one block per comment (replies indented under it), with a +/// "Page N" heading when sorted by page. Checkmarks and status replies are left out, as are +/// pop-ups; the number is the comment's position on its page. +pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String { + use std::fmt::Write; + let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.is_comment()).collect(); + // Each reply under the annotation its `/IRT` points at, in document order, gathered once. + type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>; + let mut children = Children::new(); + for r in all.iter().filter(|r| r.state.is_none()) { + if let Some(parent) = r.parent { + children.entry(parent).or_default().push(r); + } + } + // Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the + // direct parent, so following one level would leave the deeper replies out of the summary. + fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) { + use std::fmt::Write; + if depth > 64 { + return; + } + for r in children.get(&(parent.page, parent.index)).into_iter().flatten() { + let indent = " ".repeat(depth + 1); + let _ = + writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); + for line in r.contents.as_deref().unwrap_or("").lines() { + let _ = writeln!(out, "{indent}{line}"); + } + write_replies(out, children, r, depth + 1); + } + } + let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); + let mut last = usize::MAX; + let mut n = 0; + for a in &top { + if a.page != last { + last = a.page; + n = 0; + } + n += 1; + numbered.push((n, a)); + } + match sort { + SummarySort::Page => {} + SummarySort::Author => numbered.sort_by(|x, y| x.1.author.cmp(&y.1.author)), + SummarySort::Date => numbered.sort_by(|x, y| x.1.modified.cmp(&y.1.modified)), + SummarySort::Type => numbered.sort_by(|x, y| comment_kind(x.1).cmp(comment_kind(y.1))), + } + let mut out = format!("Summary of Comments on {name}\n\n"); + if numbered.is_empty() { + out.push_str("This document has no comments.\n"); + return out; + } + let mut heading = usize::MAX; + for (n, a) in numbered { + if sort == SummarySort::Page && a.page != heading { + heading = a.page; + let _ = writeln!(out, "Page: {}", a.page + 1); + } + let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_kind(a)); + if sort != SummarySort::Page { + let _ = write!(out, " Page: {}", a.page + 1); + } + let _ = writeln!(out, " Date: {}", a.modified.as_deref().unwrap_or("")); + if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { + let _ = writeln!(out, "{c}"); + } + write_replies(&mut out, &children, a, 0); + out.push('\n'); + } + out +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 56677ed5d3..a809b91021 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -20,6 +20,7 @@ pub mod camera_clean; pub mod camera_edges; pub mod catalog; pub mod commands; +mod comments; pub mod compare; mod copy_image; pub mod dates; @@ -187,6 +188,8 @@ fn record_link_scan() { }); } +use comments::comment_list; +pub use comments::{SummarySort, comment_kind, comment_summary, comment_type_name}; pub use document::{Document, TextNotice, display_bytes_for, render_password_for}; use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; @@ -349,36 +352,6 @@ pub struct RecoverySnapshot { pub encrypted: bool, } -/// The comment list, read from the object graph (as `inspect` lists comments). -fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { - let mut list: Vec = pdfcraft_annot::summaries(doc) - .into_iter() - .map(|s| pdfcraft_render::Annotation { - page: s.page, - subtype: s.subtype, - author: s.author, - contents: s.contents, - modified: s.modified.map(|m| pdfcraft_render::pretty_date(&m)), - name: s.name, - in_reply_to: s.in_reply_to, - parent: s.parent, - grouped: s.grouped, - rect: s.rect, - color: s.color, - index: s.index, - state: s.state, - quads: s.quads, - locked: s.locked, - intent: s.intent, - fill_sign: s.fill_sign, - no_rotate: s.no_rotate, - points: s.points, - }) - .collect(); - pdfcraft_render::link_replies(&mut list); - list -} - /// Seconds to add to UTC for local time (0 where unknown). fn local_utc_offset() -> i64 { #[cfg(not(target_arch = "wasm32"))] @@ -538,138 +511,6 @@ pub struct OptimizeReport { pub discarded: Vec<(Hidden, usize)>, } -/// Order of Summarize Comments. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum SummarySort { - #[default] - Page, - Author, - Date, - Type, -} - -impl SummarySort { - pub const ALL: [SummarySort; 4] = [SummarySort::Page, SummarySort::Author, SummarySort::Date, SummarySort::Type]; - pub fn name(self) -> &'static str { - match self { - SummarySort::Page => "Page", - SummarySort::Author => "Author", - SummarySort::Date => "Date", - SummarySort::Type => "Type", - } - } - pub fn parse(s: &str) -> Option { - Self::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s)) - } -} - -/// Acrobat's name for a comment type, as comment lists show it. -pub fn comment_type_name(subtype: &str) -> &str { - match subtype { - "Text" => "Sticky Note", - "FreeText" => "Text Box", - "Highlight" => "Highlight", - "Underline" => "Underline", - "StrikeOut" => "Strikethrough", - "Squiggly" => "Squiggly", - "Square" => "Rectangle", - "Circle" => "Oval", - "Line" => "Line", - "Ink" => "Pencil", - "Polygon" => "Polygon", - "PolyLine" => "Polygonal Line", - "Stamp" => "Stamp", - "Caret" => "Inserted Text", - "FileAttachment" => "File Attachment", - "Sound" => "Sound", - "Redact" => "Redaction", - other => other, - } -} - -/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. -pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { - match a.intent.as_deref() { - Some("FreeTextCallout") => "Callout", - Some("PolygonCloud") => "Cloud", - Some("FreeTextTypeWriter") => "Typewriter", - _ => comment_type_name(&a.subtype), - } -} - -/// The text of a comment summary: one block per comment (replies indented under it), with a -/// "Page N" heading when sorted by page. Checkmarks and status replies are left out, as are -/// pop-ups; the number is the comment's position on its page. -pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String { - use std::fmt::Write; - let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.is_comment()).collect(); - // Each reply under the annotation its `/IRT` points at, in document order, gathered once. - type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>; - let mut children = Children::new(); - for r in all.iter().filter(|r| r.state.is_none()) { - if let Some(parent) = r.parent { - children.entry(parent).or_default().push(r); - } - } - // Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the - // direct parent, so following one level would leave the deeper replies out of the summary. - fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) { - use std::fmt::Write; - if depth > 64 { - return; - } - for r in children.get(&(parent.page, parent.index)).into_iter().flatten() { - let indent = " ".repeat(depth + 1); - let _ = - writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); - for line in r.contents.as_deref().unwrap_or("").lines() { - let _ = writeln!(out, "{indent}{line}"); - } - write_replies(out, children, r, depth + 1); - } - } - let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); - let mut last = usize::MAX; - let mut n = 0; - for a in &top { - if a.page != last { - last = a.page; - n = 0; - } - n += 1; - numbered.push((n, a)); - } - match sort { - SummarySort::Page => {} - SummarySort::Author => numbered.sort_by(|x, y| x.1.author.cmp(&y.1.author)), - SummarySort::Date => numbered.sort_by(|x, y| x.1.modified.cmp(&y.1.modified)), - SummarySort::Type => numbered.sort_by(|x, y| comment_kind(x.1).cmp(comment_kind(y.1))), - } - let mut out = format!("Summary of Comments on {name}\n\n"); - if numbered.is_empty() { - out.push_str("This document has no comments.\n"); - return out; - } - let mut heading = usize::MAX; - for (n, a) in numbered { - if sort == SummarySort::Page && a.page != heading { - heading = a.page; - let _ = writeln!(out, "Page: {}", a.page + 1); - } - let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_kind(a)); - if sort != SummarySort::Page { - let _ = write!(out, " Page: {}", a.page + 1); - } - let _ = writeln!(out, " Date: {}", a.modified.as_deref().unwrap_or("")); - if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { - let _ = writeln!(out, "{c}"); - } - write_replies(&mut out, &children, a, 0); - out.push('\n'); - } - out -} - #[cfg(test)] mod tests; From 20974e7822ef8a9c12cfe3d9de4bcbd6c47df291 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:08:01 +0100 Subject: [PATCH 16/28] refactor(engine): move the combine and page-source helpers to sources.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 49 +++------------------------------- crates/engine/src/sources.rs | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 46 deletions(-) create mode 100644 crates/engine/src/sources.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a809b91021..274c00456b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -44,6 +44,7 @@ mod scan_fields; mod session; pub mod signature_image; pub mod signature_scan; +mod sources; mod stamp_preview; pub mod text_fonts; mod text_preview; @@ -200,6 +201,8 @@ use pdfcraft_render::{Layer, LayerOp, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; +pub use sources::{CombineSource, Combined, combine_source_check, source_page_count}; +use sources::{open_source, open_source_with}; use xfa_form::{ XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state, undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, @@ -270,17 +273,6 @@ fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: layers.iter().zip(before).any(|(l, was)| l.visible != was) } -/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all). -pub type CombineSource = (String, Arc>, Option); - -/// What a combine produced: the new PDF bytes, and the fields renamed to keep colliding names -/// separate (old fully-qualified name → new one), in order across the sources. -#[derive(Debug)] -pub struct Combined { - pub bytes: Arc>, - pub renamed_fields: Vec<(String, String)>, -} - /// A bookmark as the bookmark list shows it. #[derive(Clone, Debug, PartialEq)] pub struct BookmarkRow { @@ -407,41 +399,6 @@ pub fn guard(f: impl FnOnce() -> T) -> Result { }) } -/// Parse another PDF to copy pages from. -pub(crate) fn open_source(name: &str, bytes: &Arc>) -> Result { - open_source_with(name, bytes, None) -} - -/// [`open_source`], authenticating with `password` (user or owner) for an encrypted file. -fn open_source_with(name: &str, bytes: &Arc>, password: Option<&str>) -> Result { - source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) -} - -fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { - match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { - Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), - Ok(Ok(d)) => Ok(d), - Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), - Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword), - Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())), - Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())), - } -} - -/// Whether `bytes` can be combined, opened with `password` (user or owner) if given, and if not -/// why: what Combine would refuse it for, so the Combine files list can say so before Combine -/// is pressed. The owner (permissions) password lifts the restriction on copying pages. -pub fn combine_source_check(bytes: &Arc>, password: Option<&str>) -> Result<(), SourceProblem> { - source_document(bytes, password).map(|_| ()) -} - -/// The number of pages of `bytes`, opened with `password` (user or owner) if given, whatever -/// its permissions allow; `None` when it can't be read. -pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option { - let doc = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)).ok()?.ok()?; - pdfcraft_organize::page_count(&doc).ok() -} - /// Validate the signature fields of `cos` (written as `bytes`). fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, cache: &pdfcraft_sign::DigestCache) -> Vec { pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) diff --git a/crates/engine/src/sources.rs b/crates/engine/src/sources.rs new file mode 100644 index 0000000000..3c8083162e --- /dev/null +++ b/crates/engine/src/sources.rs @@ -0,0 +1,51 @@ +//! Other files to combine with or copy pages from: opening them, and why one can't be used. + +use std::sync::Arc; + +use crate::{EditError, SourceProblem}; + +/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all). +pub type CombineSource = (String, Arc>, Option); + +/// What a combine produced: the new PDF bytes, and the fields renamed to keep colliding names +/// separate (old fully-qualified name → new one), in order across the sources. +#[derive(Debug)] +pub struct Combined { + pub bytes: Arc>, + pub renamed_fields: Vec<(String, String)>, +} + +/// Parse another PDF to copy pages from. +pub(crate) fn open_source(name: &str, bytes: &Arc>) -> Result { + open_source_with(name, bytes, None) +} + +/// [`open_source`], authenticating with `password` (user or owner) for an encrypted file. +pub(crate) fn open_source_with(name: &str, bytes: &Arc>, password: Option<&str>) -> Result { + source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) +} + +fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { + match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { + Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), + Ok(Ok(d)) => Ok(d), + Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), + Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword), + Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())), + Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())), + } +} + +/// Whether `bytes` can be combined, opened with `password` (user or owner) if given, and if not +/// why: what Combine would refuse it for, so the Combine files list can say so before Combine +/// is pressed. The owner (permissions) password lifts the restriction on copying pages. +pub fn combine_source_check(bytes: &Arc>, password: Option<&str>) -> Result<(), SourceProblem> { + source_document(bytes, password).map(|_| ()) +} + +/// The number of pages of `bytes`, opened with `password` (user or owner) if given, whatever +/// its permissions allow; `None` when it can't be read. +pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option { + let doc = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)).ok()?.ok()?; + pdfcraft_organize::page_count(&doc).ok() +} From 4972a4e1ebb02fcda1225f2082eba73a476db4d6 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:08:14 +0100 Subject: [PATCH 17/28] refactor(engine): move the bookmark list rows to bookmarks.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/bookmarks.rs | 61 ++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 63 ++-------------------------------- 2 files changed, 64 insertions(+), 60 deletions(-) create mode 100644 crates/engine/src/bookmarks.rs diff --git a/crates/engine/src/bookmarks.rs b/crates/engine/src/bookmarks.rs new file mode 100644 index 0000000000..deaece22e4 --- /dev/null +++ b/crates/engine/src/bookmarks.rs @@ -0,0 +1,61 @@ +//! The bookmark list. + +/// A bookmark as the bookmark list shows it. +#[derive(Clone, Debug, PartialEq)] +pub struct BookmarkRow { + /// Child indices from the top level, 0-based, as the edit functions take them. + pub path: Vec, + pub title: String, + /// Shown expanded (a positive `/Count`). + pub open: bool, + /// The 0-based page it goes to. + pub page: Option, + /// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say). + pub view: pdfcraft_render::DestView, + /// The title's colour (`/C`), `None` for the default black, and its style (`/F`). + pub color: Option<[f32; 3]>, + pub italic: bool, + pub bold: bool, +} + +/// One page of the bookmark list, in outline order. +#[derive(Clone, Debug, PartialEq)] +pub struct BookmarkPage { + pub rows: Vec, + /// Where the next page starts, or `None` at the end. + pub next: Option, + /// Set only when the document's structure could not be read for editing: its listing then + /// comes from the viewer's copy of the outline, which is shorter than the file's. + pub truncated: bool, +} + +/// The rows of a viewer outline, in outline order, with their paths. +pub(crate) fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec { + let mut rows = Vec::new(); + // One entry per open level: its items, and how many of them have been listed. + let mut levels: Vec<(&[pdfcraft_render::OutlineItem], usize)> = vec![(items, 0)]; + while let Some(&(list, next)) = levels.last() { + let Some(item) = list.get(next) else { + levels.pop(); + continue; + }; + if let Some(top) = levels.last_mut() { + top.1 += 1; + } + let path: Vec = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect(); + rows.push(BookmarkRow { + path, + title: item.title.clone(), + open: item.open, + page: item.page, + view: item.view, + color: item.color, + italic: item.italic, + bold: item.bold, + }); + if !item.children.is_empty() { + levels.push((&item.children, 0)); + } + } + rows +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 274c00456b..fa18e5a66d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ pub mod actions; pub mod autotag; +mod bookmarks; pub mod build_info; pub mod camera; pub mod camera_clean; @@ -189,6 +190,8 @@ fn record_link_scan() { }); } +use bookmarks::outline_rows; +pub use bookmarks::{BookmarkPage, BookmarkRow}; use comments::comment_list; pub use comments::{SummarySort, comment_kind, comment_summary, comment_type_name}; pub use document::{Document, TextNotice, display_bytes_for, render_password_for}; @@ -273,66 +276,6 @@ fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: layers.iter().zip(before).any(|(l, was)| l.visible != was) } -/// A bookmark as the bookmark list shows it. -#[derive(Clone, Debug, PartialEq)] -pub struct BookmarkRow { - /// Child indices from the top level, 0-based, as the edit functions take them. - pub path: Vec, - pub title: String, - /// Shown expanded (a positive `/Count`). - pub open: bool, - /// The 0-based page it goes to. - pub page: Option, - /// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say). - pub view: pdfcraft_render::DestView, - /// The title's colour (`/C`), `None` for the default black, and its style (`/F`). - pub color: Option<[f32; 3]>, - pub italic: bool, - pub bold: bool, -} - -/// One page of the bookmark list, in outline order. -#[derive(Clone, Debug, PartialEq)] -pub struct BookmarkPage { - pub rows: Vec, - /// Where the next page starts, or `None` at the end. - pub next: Option, - /// Set only when the document's structure could not be read for editing: its listing then - /// comes from the viewer's copy of the outline, which is shorter than the file's. - pub truncated: bool, -} - -/// The rows of a viewer outline, in outline order, with their paths. -fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec { - let mut rows = Vec::new(); - // One entry per open level: its items, and how many of them have been listed. - let mut levels: Vec<(&[pdfcraft_render::OutlineItem], usize)> = vec![(items, 0)]; - while let Some(&(list, next)) = levels.last() { - let Some(item) = list.get(next) else { - levels.pop(); - continue; - }; - if let Some(top) = levels.last_mut() { - top.1 += 1; - } - let path: Vec = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect(); - rows.push(BookmarkRow { - path, - title: item.title.clone(), - open: item.open, - page: item.page, - view: item.view, - color: item.color, - italic: item.italic, - bold: item.bold, - }); - if !item.children.is_empty() { - levels.push((&item.children, 0)); - } - } - rows -} - /// A document's working file captured for crash recovery. #[derive(Clone, Debug)] pub struct RecoverySnapshot { From 8473403a5dce297e9ca0abdc6bde74af045e07de Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:08:38 +0100 Subject: [PATCH 18/28] refactor(engine): move layer visibility to layers.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layers.rs | 60 +++++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 56 ++-------------------------------- crates/engine/src/tests.rs | 2 +- 3 files changed, 63 insertions(+), 55 deletions(-) create mode 100644 crates/engine/src/layers.rs diff --git a/crates/engine/src/layers.rs b/crates/engine/src/layers.rs new file mode 100644 index 0000000000..c2f4f63714 --- /dev/null +++ b/crates/engine/src/layers.rs @@ -0,0 +1,60 @@ +//! Layer (optional content) visibility: what the view shows and what printing writes. + +use std::sync::Arc; + +use pdfcraft_render::{Layer, LayerOp, RenderPool}; + +use crate::{Document, render_threads}; + +/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy +/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its +/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties` +/// is left alone. +pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) { + use pdfcraft_cos::{Dict, ObjRef, Object}; + let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return }; + let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return }; + let Some(properties) = root.get(b"OCProperties").cloned() else { return }; + let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return }; + let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new); + let refs = + |visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect()); + config.set(b"BaseState".to_vec(), Object::name("ON")); + config.set(b"ON".to_vec(), refs(true)); + config.set(b"OFF".to_vec(), refs(false)); + properties.set(b"D".to_vec(), Object::Dict(config)); + root.set(b"OCProperties".to_vec(), Object::Dict(properties)); + cos.set(root_ref, Object::Dict(root)); +} + +/// Render `doc` with the layer visibility in its `info.layers`. +pub(crate) fn use_layer_choices(doc: &mut Document) { + let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); + doc.config.layers = Arc::new(overrides); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); +} + +/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the +/// state the changes before it left. Returns whether any layer ended up changed. +pub(crate) fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { + let before: Vec = layers.iter().map(|l| l.visible).collect(); + let index: std::collections::HashMap<(u32, u16), usize> = layers.iter().enumerate().map(|(i, l)| (l.id, i)).collect(); + for &(op, ocg) in changes { + let Some(layer) = index.get(&ocg).and_then(|&i| layers.get_mut(i)) else { continue }; + let on = match op { + LayerOp::On => true, + LayerOp::Off => false, + LayerOp::Toggle => !layer.visible, + }; + layer.visible = on; + // Turning a layer off leaves the rest of its groups alone. + if on && preserve_rb { + for other in groups.iter().filter(|g| g.contains(&ocg)).flatten().filter(|&&o| o != ocg) { + if let Some(l) = index.get(other).and_then(|&i| layers.get_mut(i)) { + l.visible = false; + } + } + } + } + layers.iter().zip(before).any(|(l, was)| l.visible != was) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fa18e5a66d..7a93fc87b0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -32,6 +32,7 @@ pub mod export; pub mod field_table; mod history; pub mod js; +mod layers; pub mod links; pub mod ocr; mod open; @@ -199,8 +200,8 @@ use document::{display_bytes, info_fields, recovered_render_password, same_pages pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; pub use history::Label; +use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; -use pdfcraft_render::{Layer, LayerOp, RenderPool}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; @@ -223,59 +224,6 @@ fn render_threads() -> usize { std::thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(4).clamp(2, 8) - 1 } -/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy -/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its -/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties` -/// is left alone. -fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) { - use pdfcraft_cos::{Dict, ObjRef, Object}; - let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return }; - let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return }; - let Some(properties) = root.get(b"OCProperties").cloned() else { return }; - let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return }; - let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new); - let refs = - |visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect()); - config.set(b"BaseState".to_vec(), Object::name("ON")); - config.set(b"ON".to_vec(), refs(true)); - config.set(b"OFF".to_vec(), refs(false)); - properties.set(b"D".to_vec(), Object::Dict(config)); - root.set(b"OCProperties".to_vec(), Object::Dict(properties)); - cos.set(root_ref, Object::Dict(root)); -} - -/// Render `doc` with the layer visibility in its `info.layers`. -fn use_layer_choices(doc: &mut Document) { - let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); - doc.config.layers = Arc::new(overrides); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); -} - -/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the -/// state the changes before it left. Returns whether any layer ended up changed. -fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { - let before: Vec = layers.iter().map(|l| l.visible).collect(); - let index: std::collections::HashMap<(u32, u16), usize> = layers.iter().enumerate().map(|(i, l)| (l.id, i)).collect(); - for &(op, ocg) in changes { - let Some(layer) = index.get(&ocg).and_then(|&i| layers.get_mut(i)) else { continue }; - let on = match op { - LayerOp::On => true, - LayerOp::Off => false, - LayerOp::Toggle => !layer.visible, - }; - layer.visible = on; - // Turning a layer off leaves the rest of its groups alone. - if on && preserve_rb { - for other in groups.iter().filter(|g| g.contains(&ocg)).flatten().filter(|&&o| o != ocg) { - if let Some(l) = index.get(other).and_then(|&i| layers.get_mut(i)) { - l.visible = false; - } - } - } - } - layers.iter().zip(before).any(|(l, was)| l.visible != was) -} - /// A document's working file captured for crash recovery. #[derive(Clone, Debug)] pub struct RecoverySnapshot { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a30d81b74b..c622af21b4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use super::document::is_placeholder_title; use super::edit::check_permission; use super::*; -use pdfcraft_render::{OpenError, ReadingColors}; +use pdfcraft_render::{Layer, LayerOp, OpenError, ReadingColors}; /// A minimal `n`-page document; page `i` shows "Page i+1". pub(crate) fn fixture(n: usize) -> Vec { From 94b611081941737961d30dcef3e6b5fae75ad1af Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:09:11 +0100 Subject: [PATCH 19/28] refactor(engine): move placed pictures and mark sources to pictures.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 57 ++------------------------------- crates/engine/src/pictures.rs | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 54 deletions(-) create mode 100644 crates/engine/src/pictures.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7a93fc87b0..ea9c267ae0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -37,6 +37,7 @@ pub mod links; pub mod ocr; mod open; pub mod optimizer; +mod pictures; pub mod print_raster; pub mod printcheck; mod protection; @@ -202,6 +203,8 @@ pub use error::{EditError, SourceProblem}; pub use history::Label; use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; +pub use pictures::MarkFile; +use pictures::{embed_image, fit_picture, mark_source}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; @@ -295,60 +298,6 @@ fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) } -/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` -/// (0-based) of a PDF. -#[derive(Clone, Debug, PartialEq)] -pub struct MarkFile { - pub name: String, - pub bytes: Arc>, - pub page: usize, -} - -/// Embed an image that is placed on a page, turned the way its EXIF Orientation says. -pub(crate) fn embed_image( - doc: &mut pdfcraft_cos::Document, - name: &str, - bytes: &[u8], -) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { - pdfcraft_create::image_xobject(doc, name, bytes) -} - -/// The largest box of `w`:`h` shape inside `rect`, centred in it: a stamp's picture keeps its -/// shape in a box of any other shape. A box that is already that shape (to within rounding) is -/// kept exactly, so callers that size it themselves get their box back. An empty or non-finite -/// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse. -pub(crate) fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { - if !rect.iter().all(|v| v.is_finite()) { - return rect; - } - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - let (bw, bh) = (rect[2] - rect[0], rect[3] - rect[1]); - let usable = |v: f64| v.is_finite() && v > 0.0; - if !(usable(bw) && usable(bh) && usable(w) && usable(h)) { - return rect; - } - let k = (bw / w).min(bh / h); - let (fw, fh) = (w * k, h * k); - if (fw - bw).abs() <= bw * 1e-9 && (fh - bh).abs() <= bh * 1e-9 { - return rect; - } - let (cx, cy) = (rect[0] + bw / 2.0, rect[1] + bh / 2.0); - [cx - fw / 2.0, cy - fh / 2.0, cx + fw / 2.0, cy + fh / 2.0] -} - -/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. -pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { - let head = &f.bytes[..f.bytes.len().min(1024)]; - if head.windows(5).any(|w| w == b"%PDF-") { - let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?; - let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?; - Ok(pdfcraft_edit::MarkSource { xobject, size, image: false }) - } else { - let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?; - Ok(pdfcraft_edit::MarkSource { xobject, size, image: true }) - } -} - /// What Optimize PDF did. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct OptimizeReport { diff --git a/crates/engine/src/pictures.rs b/crates/engine/src/pictures.rs new file mode 100644 index 0000000000..b29941720a --- /dev/null +++ b/crates/engine/src/pictures.rs @@ -0,0 +1,59 @@ +//! Pictures placed on pages: added images, and the pictures of watermarks and backgrounds. + +use std::sync::Arc; + +use crate::EditError; + +/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` +/// (0-based) of a PDF. +#[derive(Clone, Debug, PartialEq)] +pub struct MarkFile { + pub name: String, + pub bytes: Arc>, + pub page: usize, +} + +/// Embed an image that is placed on a page, turned the way its EXIF Orientation says. +pub(crate) fn embed_image( + doc: &mut pdfcraft_cos::Document, + name: &str, + bytes: &[u8], +) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { + pdfcraft_create::image_xobject(doc, name, bytes) +} + +/// The largest box of `w`:`h` shape inside `rect`, centred in it: a stamp's picture keeps its +/// shape in a box of any other shape. A box that is already that shape (to within rounding) is +/// kept exactly, so callers that size it themselves get their box back. An empty or non-finite +/// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse. +pub(crate) fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { + if !rect.iter().all(|v| v.is_finite()) { + return rect; + } + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let (bw, bh) = (rect[2] - rect[0], rect[3] - rect[1]); + let usable = |v: f64| v.is_finite() && v > 0.0; + if !(usable(bw) && usable(bh) && usable(w) && usable(h)) { + return rect; + } + let k = (bw / w).min(bh / h); + let (fw, fh) = (w * k, h * k); + if (fw - bw).abs() <= bw * 1e-9 && (fh - bh).abs() <= bh * 1e-9 { + return rect; + } + let (cx, cy) = (rect[0] + bw / 2.0, rect[1] + bh / 2.0); + [cx - fw / 2.0, cy - fh / 2.0, cx + fw / 2.0, cy + fh / 2.0] +} + +/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. +pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { + let head = &f.bytes[..f.bytes.len().min(1024)]; + if head.windows(5).any(|w| w == b"%PDF-") { + let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?; + let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?; + Ok(pdfcraft_edit::MarkSource { xobject, size, image: false }) + } else { + let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?; + Ok(pdfcraft_edit::MarkSource { xobject, size, image: true }) + } +} From fc6072154d8321ff48cb802189b1bc856bc958be Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:09:23 +0100 Subject: [PATCH 20/28] refactor(engine): move the Fill & Sign geometry helpers to fill_sign.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/fill_sign.rs | 47 ++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 45 ++------------------------------ 2 files changed, 49 insertions(+), 43 deletions(-) create mode 100644 crates/engine/src/fill_sign.rs diff --git a/crates/engine/src/fill_sign.rs b/crates/engine/src/fill_sign.rs new file mode 100644 index 0000000000..08739addfa --- /dev/null +++ b/crates/engine/src/fill_sign.rs @@ -0,0 +1,47 @@ +//! Fill & Sign geometry: typed signatures, and boxes placed as a turned page displays them. + +use crate::{Shape, script_outline, signature_image}; + +/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user +/// space, vertically centred) and `height` points tall. `None` for text with no outlines. +/// +/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`): +/// the box runs along the displayed axes, like an image signature's +/// ([`SignatureImage::rect`]), and the outlines are turned back so the name reads across. +pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option { + let o = script_outline(text); + let [left, bottom, right, top] = o.bounds(); + let span = (top - bottom).max(0.1); + let width = right - left; + if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) { + return None; + } + let k = height / span; + let rect = signature_image::upright_box(rotation, at, width * k, height)?; + // Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box + // is at this fraction of the user-space box. Exact for an unturned page. + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); + let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)]; + let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect(); + Some(Shape::TypedSignature { rect, contours }) +} + +/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`). +pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] { + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); + [at[0] + a * right + c * up, at[1] + b * right + d * up] +} + +/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`, +/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a +/// non-finite point. +pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> { + signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height) +} + +/// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types +/// it. `None` for text with no outlines. +pub fn typed_signature_graphic(text: &str) -> Option { + let o = script_outline(text); + o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours)) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ea9c267ae0..25428ebf67 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -30,6 +30,7 @@ mod edit; mod error; pub mod export; pub mod field_table; +mod fill_sign; mod history; pub mod js; mod layers; @@ -84,49 +85,6 @@ pub enum ImageEdit { Delete, } -/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user -/// space, vertically centred) and `height` points tall. `None` for text with no outlines. -/// -/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`): -/// the box runs along the displayed axes, like an image signature's -/// ([`SignatureImage::rect`]), and the outlines are turned back so the name reads across. -pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option { - let o = script_outline(text); - let [left, bottom, right, top] = o.bounds(); - let span = (top - bottom).max(0.1); - let width = right - left; - if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) { - return None; - } - let k = height / span; - let rect = signature_image::upright_box(rotation, at, width * k, height)?; - // Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box - // is at this fraction of the user-space box. Exact for an unturned page. - let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); - let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)]; - let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect(); - Some(Shape::TypedSignature { rect, contours }) -} -/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`). -pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] { - let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); - [at[0] + a * right + c * up, at[1] + b * right + d * up] -} - -/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`, -/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a -/// non-finite point. -pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> { - signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height) -} - -/// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types -/// it. `None` for text with no outlines. -pub fn typed_signature_graphic(text: &str) -> Option { - let o = script_outline(text); - o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours)) -} - pub use links::{ MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, }; @@ -200,6 +158,7 @@ pub use document::{Document, TextNotice, display_bytes_for, render_password_for} use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; +pub use fill_sign::{displayed_offset, text_box_at, typed_signature_graphic, typed_signature_shape}; pub use history::Label; use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; From 8ca4bf641f05fb96d55dbbfc28292d8943681ce0 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:09:38 +0100 Subject: [PATCH 21/28] refactor(engine): move the page filters to page_filter.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 42 ++------------------------------ crates/engine/src/page_filter.rs | 41 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 40 deletions(-) create mode 100644 crates/engine/src/page_filter.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 25428ebf67..825494f26f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -38,6 +38,7 @@ pub mod links; pub mod ocr; mod open; pub mod optimizer; +mod page_filter; mod pictures; pub mod print_raster; pub mod printcheck; @@ -162,6 +163,7 @@ pub use fill_sign::{displayed_offset, text_box_at, typed_signature_graphic, type pub use history::Label; use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; +pub use page_filter::{PageOrientation, PageParity, filter_pages}; pub use pictures::MarkFile; use pictures::{embed_image, fit_picture, mark_source}; use protection::unrestricted; @@ -269,43 +271,3 @@ pub struct OptimizeReport { #[cfg(test)] mod tests; - -/// Page filters of Acrobat's Rotate Pages and page selection: even/odd page numbers and -/// orientation (as displayed). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum PageParity { - #[default] - Both, - Even, - Odd, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum PageOrientation { - #[default] - Both, - Landscape, - Portrait, -} - -/// The pages of `pages` (0-based) that pass the filters. -pub fn filter_pages(info: &pdfcraft_render::DocInfo, pages: &[usize], parity: PageParity, orientation: PageOrientation) -> Vec { - pages - .iter() - .copied() - .filter(|p| match parity { - PageParity::Both => true, - // Page numbers are 1-based: index 1 is page 2, an even page. - PageParity::Even => p % 2 == 1, - PageParity::Odd => p % 2 == 0, - }) - .filter(|p| { - let Some(pg) = info.pages.get(*p) else { return false }; - match orientation { - PageOrientation::Both => true, - PageOrientation::Landscape => pg.width > pg.height, - PageOrientation::Portrait => pg.width <= pg.height, - } - }) - .collect() -} diff --git a/crates/engine/src/page_filter.rs b/crates/engine/src/page_filter.rs new file mode 100644 index 0000000000..aaedfbd65c --- /dev/null +++ b/crates/engine/src/page_filter.rs @@ -0,0 +1,41 @@ +//! Page filters: even or odd page numbers, and orientation as displayed. + +/// Page filters of Acrobat's Rotate Pages and page selection: even/odd page numbers and +/// orientation (as displayed). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageParity { + #[default] + Both, + Even, + Odd, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageOrientation { + #[default] + Both, + Landscape, + Portrait, +} + +/// The pages of `pages` (0-based) that pass the filters. +pub fn filter_pages(info: &pdfcraft_render::DocInfo, pages: &[usize], parity: PageParity, orientation: PageOrientation) -> Vec { + pages + .iter() + .copied() + .filter(|p| match parity { + PageParity::Both => true, + // Page numbers are 1-based: index 1 is page 2, an even page. + PageParity::Even => p % 2 == 1, + PageParity::Odd => p % 2 == 0, + }) + .filter(|p| { + let Some(pg) = info.pages.get(*p) else { return false }; + match orientation { + PageOrientation::Both => true, + PageOrientation::Landscape => pg.width > pg.height, + PageOrientation::Portrait => pg.width <= pg.height, + } + }) + .collect() +} From 51fbed293bbc00f7470f5a09de44fa4d715cd665 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:09:57 +0100 Subject: [PATCH 22/28] refactor(engine): move the test-only hooks to test_hooks.rs Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 52 +++------------------------------ crates/engine/src/test_hooks.rs | 42 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 48 deletions(-) create mode 100644 crates/engine/src/test_hooks.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 825494f26f..ebd0b5c7a1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -51,6 +51,8 @@ pub mod signature_image; pub mod signature_scan; mod sources; mod stamp_preview; +#[cfg(test)] +mod test_hooks; pub mod text_fonts; mod text_preview; pub mod word_count; @@ -103,54 +105,6 @@ pub type SplitPart = (usize, usize, Arc>); use std::sync::Arc; -#[cfg(test)] -thread_local! { - static SECONDARY_SCAN_COUNTS: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) }; - static REFRESH_PANICS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -#[cfg(test)] -fn panic_on_refresh(count: usize) { - REFRESH_PANICS.with(|n| n.set(count)); -} - -#[cfg(test)] -fn check_refresh_panic() { - REFRESH_PANICS.with(|n| { - let rem = n.get(); - if rem > 0 { - n.set(rem - 1); - panic!("injected refresh panic"); - } - }); -} - -#[cfg(test)] -fn reset_secondary_scan_counts() { - SECONDARY_SCAN_COUNTS.with(|counts| counts.set((0, 0))); -} - -#[cfg(test)] -fn secondary_scan_counts() -> (usize, usize) { - SECONDARY_SCAN_COUNTS.with(std::cell::Cell::get) -} - -#[cfg(test)] -fn record_marker_scan() { - SECONDARY_SCAN_COUNTS.with(|counts| { - let (markers, links) = counts.get(); - counts.set((markers + 1, links)); - }); -} - -#[cfg(test)] -fn record_link_scan() { - SECONDARY_SCAN_COUNTS.with(|counts| { - let (markers, links) = counts.get(); - counts.set((markers, links + 1)); - }); -} - use bookmarks::outline_rows; pub use bookmarks::{BookmarkPage, BookmarkRow}; use comments::comment_list; @@ -171,6 +125,8 @@ pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; pub use sources::{CombineSource, Combined, combine_source_check, source_page_count}; use sources::{open_source, open_source_with}; +#[cfg(test)] +use test_hooks::{check_refresh_panic, panic_on_refresh, record_link_scan, record_marker_scan, reset_secondary_scan_counts, secondary_scan_counts}; use xfa_form::{ XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state, undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, diff --git a/crates/engine/src/test_hooks.rs b/crates/engine/src/test_hooks.rs new file mode 100644 index 0000000000..d6e504599e --- /dev/null +++ b/crates/engine/src/test_hooks.rs @@ -0,0 +1,42 @@ +//! Test-only hooks: injected refresh panics and counts of the secondary page scans. + +thread_local! { + static SECONDARY_SCAN_COUNTS: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) }; + static REFRESH_PANICS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +pub(crate) fn panic_on_refresh(count: usize) { + REFRESH_PANICS.with(|n| n.set(count)); +} + +pub(crate) fn check_refresh_panic() { + REFRESH_PANICS.with(|n| { + let rem = n.get(); + if rem > 0 { + n.set(rem - 1); + panic!("injected refresh panic"); + } + }); +} + +pub(crate) fn reset_secondary_scan_counts() { + SECONDARY_SCAN_COUNTS.with(|counts| counts.set((0, 0))); +} + +pub(crate) fn secondary_scan_counts() -> (usize, usize) { + SECONDARY_SCAN_COUNTS.with(std::cell::Cell::get) +} + +pub(crate) fn record_marker_scan() { + SECONDARY_SCAN_COUNTS.with(|counts| { + let (markers, links) = counts.get(); + counts.set((markers + 1, links)); + }); +} + +pub(crate) fn record_link_scan() { + SECONDARY_SCAN_COUNTS.with(|counts| { + let (markers, links) = counts.get(); + counts.set((markers, links + 1)); + }); +} From dc224884cb9c50307ef589b683405d17ee3be4e3 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:10:38 +0100 Subject: [PATCH 23/28] refactor(engine): gather lib.rs's imports and re-exports under the module list Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 77 ++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ebd0b5c7a1..78e5ea1eb4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -59,6 +59,11 @@ pub mod word_count; pub mod xfa; mod xfa_form; +use std::sync::Arc; + +pub use links::{ + MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, +}; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use stamp_preview::StampPreview; @@ -66,45 +71,6 @@ pub use text_preview::TextEditPreview; pub use reexports::*; -/// A change to an existing page image. -#[derive(Clone, Debug, PartialEq)] -pub enum ImageEdit { - /// Move and resize it to this box (user space). - Move([f64; 4]), - /// Quarter turns clockwise about its centre. - Rotate(i32), - /// Any angle in degrees clockwise about its centre (the unit is degrees, not quarter turns). - RotateBy(f64), - Flip { - horizontal: bool, - }, - /// Show only this box of it (user space), the pixels staying where they are. - Crop([f64; 4]), - /// Draw this image file (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) in its place. - Replace { - name: String, - bytes: Arc>, - }, - Delete, -} - -pub use links::{ - MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, -}; - -/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a -/// filled-in PDF form), as CSV with one row per file. -pub fn merge_data_files(files: &[(String, Vec)]) -> Result { - let rows = - files.iter().map(|(name, bytes)| pdfcraft_xfdf::data_values(bytes).map_err(|e| format!("{name}: {e}"))).collect::, _>>()?; - Ok(pdfcraft_xfdf::merge_csv(&rows)) -} - -/// One file produced by a split: (1-based first page, last page, PDF bytes). -pub type SplitPart = (usize, usize, Arc>); - -use std::sync::Arc; - use bookmarks::outline_rows; pub use bookmarks::{BookmarkPage, BookmarkRow}; use comments::comment_list; @@ -132,6 +98,39 @@ use xfa_form::{ undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, }; +/// A change to an existing page image. +#[derive(Clone, Debug, PartialEq)] +pub enum ImageEdit { + /// Move and resize it to this box (user space). + Move([f64; 4]), + /// Quarter turns clockwise about its centre. + Rotate(i32), + /// Any angle in degrees clockwise about its centre (the unit is degrees, not quarter turns). + RotateBy(f64), + Flip { + horizontal: bool, + }, + /// Show only this box of it (user space), the pixels staying where they are. + Crop([f64; 4]), + /// Draw this image file (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) in its place. + Replace { + name: String, + bytes: Arc>, + }, + Delete, +} + +/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a +/// filled-in PDF form), as CSV with one row per file. +pub fn merge_data_files(files: &[(String, Vec)]) -> Result { + let rows = + files.iter().map(|(name, bytes)| pdfcraft_xfdf::data_values(bytes).map_err(|e| format!("{name}: {e}"))).collect::, _>>()?; + Ok(pdfcraft_xfdf::merge_csv(&rows)) +} + +/// One file produced by a split: (1-based first page, last page, PDF bytes). +pub type SplitPart = (usize, usize, Arc>); + /// Stable identifier of an open document within a session. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct DocId(pub u64); From 00596b69a6c4cf7a46d83f3ed9723815d55ae4a0 Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:12:15 +0100 Subject: [PATCH 24/28] refactor(engine): keep the moved docs' intra-doc links resolving (document, fill_sign) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/document.rs | 6 +++--- crates/engine/src/fill_sign.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 0c85f51af6..7023caa55e 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -59,7 +59,7 @@ pub struct Document { /// XFA forms: what was approximated, rewritten or could not be written to the XFA data /// (also in `info.warnings`, kept there when the document is re-read). pub xfa_warnings: Vec, - /// What the last successful [`Session::apply`] wrote that its fonts can't draw: kept as typed, + /// What the last successful [`Session::apply`](crate::Session::apply) wrote that its fonts can't draw: kept as typed, /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. pub edit_notices: Vec, } @@ -440,7 +440,7 @@ impl Document { } /// A password restricts the document and it wasn't opened with the owner password: entering - /// that password ([`Session::unlock`]) lifts the restrictions. + /// that password ([`Session::unlock`](crate::Session::unlock)) lifts the restrictions. pub fn can_unlock(&self) -> bool { self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p)) } @@ -629,7 +629,7 @@ pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc>, password: Option<&str>) -> Option { diff --git a/crates/engine/src/fill_sign.rs b/crates/engine/src/fill_sign.rs index 08739addfa..1bd8c68d05 100644 --- a/crates/engine/src/fill_sign.rs +++ b/crates/engine/src/fill_sign.rs @@ -7,7 +7,7 @@ use crate::{Shape, script_outline, signature_image}; /// /// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`): /// the box runs along the displayed axes, like an image signature's -/// ([`SignatureImage::rect`]), and the outlines are turned back so the name reads across. +/// ([`SignatureImage::rect`](crate::SignatureImage::rect)), and the outlines are turned back so the name reads across. pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option { let o = script_outline(text); let [left, bottom, right, top] = o.bounds(); From 323f569b37b2982b73bde3bddc3453479da56dbe Mon Sep 17 00:00:00 2001 From: alehel Date: Sun, 25 Oct 2026 23:14:58 +0100 Subject: [PATCH 25/28] docs: point form-performance at the engine's session.rs and edit.rs Co-Authored-By: Claude Opus 5.5 --- docs/form-performance.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/form-performance.md b/docs/form-performance.md index c00adf0913..1ef72dfc6d 100644 --- a/docs/form-performance.md +++ b/docs/form-performance.md @@ -8,8 +8,9 @@ not fresh measurements on upstream main. No private document is needed for the b - `crates/ui-egui/src/forms_ui.rs` queues `Edit::SetFieldValue` for a text commit or checkbox toggle. `crates/automation/src/forms.rs::form_fill` uses the same edit API. -- `crates/engine/src/lib.rs::Session::apply` runs edits on a copy, preserving rollback and undo. - `run_edit` calls `pdfcraft_forms::set_value_with` with an engine `JsRunner`. +- `crates/engine/src/session.rs::Session::apply` runs edits on a copy, preserving rollback and + undo. `run_edit` (`crates/engine/src/edit.rs`) calls `pdfcraft_forms::set_value_with` with an + engine `JsRunner`. - `crates/forms/src/lib.rs` validates and writes the edited value, then runs one calculation pass. `/CO` precedes calculated fields omitted from `/CO`. Each script sees prior writes. There is no dependency graph or dependency-based filtering: dynamic references and script @@ -265,6 +266,8 @@ checks, not hosted CI or a new performance speedup claim. ## Revision history +- 2026-10-25: point the architecture notes at `session.rs` and `edit.rs`, where `Session::apply` + and `run_edit` moved when the engine's `lib.rs` was split. - 2026-10-19: refresh PR #3318 onto upstream `e397efa8`, retain upstream roadmap additions and all performance changes, and repeat focused, scoped, benchmark and headless validation. From fc578a0b02e9a577b4bdb9e7cc2d9113623e9f4f Mon Sep 17 00:00:00 2001 From: skylar Date: Sun, 25 Oct 2026 16:36:22 -0600 Subject: [PATCH 26/28] chore(deps): bump 11 safe dependency updates --- Cargo.lock | 349 +++++++++++++---------------------- apps/pdfcraft-cli/Cargo.toml | 2 +- crates/automation/Cargo.toml | 2 +- crates/sign/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 2 +- crates/xfa/Cargo.toml | 2 +- 6 files changed, 138 insertions(+), 221 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd3e1930fd..54be313cc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,21 +215,21 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -443,9 +443,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -454,9 +454,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -489,15 +489,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -508,10 +499,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -519,6 +513,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -576,7 +580,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -645,7 +649,7 @@ dependencies = [ "oneshot", "pastey", "portable-atomic", - "rand 0.10.3", + "rand", "regress", "rustc-hash 2.1.3", "ryu-js", @@ -736,6 +740,30 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "bp256" version = "0.14.0" @@ -1164,7 +1192,7 @@ dependencies = [ "dispatch2", "egui", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", ] @@ -1175,7 +1203,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd0 dependencies = [ "egui", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "serde", @@ -1473,7 +1501,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1594,7 +1622,7 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek", "ed25519", - "sha2 0.11.0", + "sha2 0.11.1", "subtle", "zeroize", ] @@ -1617,7 +1645,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1689,7 +1717,7 @@ dependencies = [ "egui", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", @@ -1920,7 +1948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2090,7 +2118,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", ] @@ -2409,7 +2437,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3241,10 +3269,10 @@ dependencies = [ "log", "md-5", "nom", - "rand 0.10.3", + "rand", "rangemap", "rayon", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", "weezl 0.2.1", @@ -3346,7 +3374,7 @@ dependencies = [ "crossbeam-channel", "dpi", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3551,9 +3579,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -3582,7 +3610,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3632,7 +3660,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3643,7 +3671,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -3699,7 +3727,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3710,7 +3738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3746,7 +3774,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3770,7 +3798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3815,7 +3843,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -3913,7 +3941,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3926,7 +3954,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3940,7 +3968,7 @@ dependencies = [ "fiat-crypto", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3954,7 +3982,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -4065,7 +4093,7 @@ dependencies = [ name = "pdfcraft-automation" version = "0.14.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "flate2", "image", "pdfcraft-cos", @@ -4079,7 +4107,7 @@ dependencies = [ "png 0.18.1", "rustix 1.1.5", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "tempfile", ] @@ -4087,7 +4115,7 @@ dependencies = [ name = "pdfcraft-cli" version = "0.14.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "pdfcraft-audit", "pdfcraft-automation", "pdfcraft-cos", @@ -4151,7 +4179,7 @@ dependencies = [ "aes", "md-5", "proptest", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", ] @@ -4398,7 +4426,7 @@ version = "0.14.0" dependencies = [ "aes", "aws-lc-rs", - "base64 0.22.1", + "base64 0.23.1", "bp256", "bp384", "cbc", @@ -4423,7 +4451,7 @@ dependencies = [ "rustls-cng", "security-framework", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "sha3", "thiserror 2.0.21", ] @@ -4437,7 +4465,7 @@ name = "pdfcraft-ui-egui" version = "0.14.0" dependencies = [ "arboard", - "base64 0.22.1", + "base64 0.23.1", "block2 0.6.2", "bytemuck", "chrono", @@ -4453,7 +4481,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "pdfcraft-cos", @@ -4498,13 +4526,13 @@ dependencies = [ name = "pdfcraft-xfa" version = "0.14.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "image", "pdfcraft-cos", "pdfcraft-fonts", "png 0.18.1", "roxmltree", - "sha2 0.11.0", + "sha2 0.11.1", "thiserror 2.0.21", ] @@ -4750,9 +4778,9 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.10.2" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" dependencies = [ "base64 0.23.1", "indexmap", @@ -4852,15 +4880,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "presser" version = "0.3.1" @@ -4944,16 +4963,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -5027,7 +5047,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.3", + "rand", "rand_pcg", "ring", "rustc-hash 2.1.3", @@ -5051,7 +5071,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5075,16 +5095,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.3" @@ -5096,31 +5106,12 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -5138,11 +5129,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -5169,7 +5160,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -5362,7 +5353,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -5601,7 +5592,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5633,9 +5624,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -5671,7 +5662,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5843,9 +5834,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -5942,9 +5933,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -6100,7 +6091,7 @@ dependencies = [ "const-oid 0.9.6", "der 0.7.10", "hex", - "rand 0.10.3", + "rand", "reqwest", "rustls-pki-types", "rustls-webpki", @@ -6555,10 +6546,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7012,9 +7003,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.4.2" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" dependencies = [ "base64 0.23.1", "flate2", @@ -7029,9 +7020,9 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ "base64 0.23.1", "http", @@ -7453,7 +7444,7 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", @@ -7609,7 +7600,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -7670,7 +7661,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7791,7 +7782,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -7800,16 +7791,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -7827,31 +7809,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -7869,96 +7834,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -8051,9 +7968,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -8235,7 +8152,7 @@ dependencies = [ "png 0.18.1", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "toml", ] diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml index 7b5f9a82f3..3d9d87a812 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -21,7 +21,7 @@ pdfcraft-cos = { workspace = true } pdfcraft-preflight = { workspace = true } pdfcraft-automation = { workspace = true } pdfcraft-platform = { workspace = true } -base64 = "0.22" +base64 = "0.23" [dev-dependencies] # Decodes `render` output in tests/it/owner_password.rs; already a dependency of the engine. diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index bed579ab84..a6e43de1f0 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -17,7 +17,7 @@ pdfcraft-platform = { workspace = true } serde_json = { workspace = true } sha2 = "0.11" tempfile = "3.27" -base64 = { version = "0.22", optional = true } +base64 = { version = "0.23", optional = true } [features] diff --git a/crates/sign/Cargo.toml b/crates/sign/Cargo.toml index 4f1f0d4094..3c0f207a98 100644 --- a/crates/sign/Cargo.toml +++ b/crates/sign/Cargo.toml @@ -18,7 +18,7 @@ pdfcraft-model = { workspace = true } thiserror.workspace = true # ASN.1 UTCTime/GeneralizedTime conversion goes through chrono (no hand-rolled calendar code). chrono = { workspace = true } -base64 = "0.22" +base64 = "0.23" sha1 = "0.11" sha2 = "0.11" p256 = { version = "0.14", default-features = false, features = ["ecdsa", "std"] } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c096254995..5279f56248 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -33,7 +33,7 @@ bytemuck = { version = "1", features = ["extern_crate_alloc"] } rfd = { workspace = true } png = "0.18" -base64 = "0.22" +base64 = "0.23" # Unicode bidi paragraph segmentation for right-to-left comment wrapping; egui 0.37's epaint owns # display reordering. Already in the tree through egui’s epaint. unicode-bidi = "0.3" diff --git a/crates/xfa/Cargo.toml b/crates/xfa/Cargo.toml index dcec1e75bc..d917018057 100644 --- a/crates/xfa/Cargo.toml +++ b/crates/xfa/Cargo.toml @@ -14,7 +14,7 @@ workspace = true pdfcraft-cos = { workspace = true } pdfcraft-fonts = { workspace = true } roxmltree = "0.20" -base64 = "0.22" +base64 = "0.23" png = "0.18" sha2 = "0.11" image = { version = "0.25", default-features = false, features = ["bmp", "gif", "tiff"] } From 01b696df2c79636ff6ea737e6b02eff634d99f6c Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:11:45 -0400 Subject: [PATCH 27/28] test(ui): CJK interface tests skip without craft-fonts (#4706) * test(ui): CJK interface tests skip without craft-fonts * test(ui): a build that requires craft-fonts never skips the CJK interface tests --- crates/ui-egui/tests/it/combine_grid.rs | 4 +++ crates/ui-egui/tests/it/commands.rs | 3 ++ crates/ui-egui/tests/it/content.rs | 6 ++++ crates/ui-egui/tests/it/control.rs | 21 +++++++++++++ crates/ui-egui/tests/it/date_format.rs | 7 +++++ crates/ui-egui/tests/it/editing.rs | 6 ++++ crates/ui-egui/tests/it/pickers.rs | 3 ++ crates/ui-egui/tests/it/support/mod.rs | 41 +++++++++++++++++++++++-- 8 files changed, 89 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/it/combine_grid.rs b/crates/ui-egui/tests/it/combine_grid.rs index c6f2e3e721..0339e0b6bb 100644 --- a/crates/ui-egui/tests/it/combine_grid.rs +++ b/crates/ui-egui/tests/it/combine_grid.rs @@ -530,6 +530,10 @@ fn combine_follows_the_order_shown_in_the_grid() { #[test] fn long_and_unusual_names_are_shortened_on_the_card_but_kept_whole() { + if crate::support::skip_without_glyphs("long_and_unusual_names_are_shortened_on_the_card_but_kept_whole", "en", "日本語のファイル名.pdf") + { + return; + } let long = "Überweisungsbestätigung für das dritte Quartal 2026 – Endfassung (unterschrieben).pdf"; let mut h = grid_of(vec![(long, fixture(1)), ("日本語のファイル名.pdf", fixture(1)), ("a&b 'd'.pdf", fixture(1))], vec2(1400.0, 900.0)); // The card's label is the whole name; only the drawing is shortened. diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index ff82820148..15c23c6296 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -623,6 +623,9 @@ fn palette_ime_idle_frames_and_reopen_do_not_interrupt_composition() { #[test] fn palette_ime_chinese_preedit_commit_and_cancel_remain_uninterrupted() { + if crate::support::skip_without_glyphs("palette_ime_chinese_preedit_commit_and_cancel_remain_uninterrupted", "en", "你") { + return; + } let mut h = harness(); let mut interruptions = Vec::new(); h.state_mut().run_command("view.palette"); diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 0353a7d2b3..4a7927f692 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -335,6 +335,9 @@ fn a_picture_on_the_clipboard_is_pasted_onto_the_page() { /// written to the page as `?` (or lost). #[test] fn text_the_standard_fonts_cant_draw_stays_in_the_editor() { + if crate::support::skip_without_glyphs("text_the_standard_fonts_cant_draw_stays_in_the_editor", "en", "Hello 世界") { + return; + } let _gpu = gpu(); let mut h = harness(); assert!(h.state_mut().execute("edit.text")); @@ -372,6 +375,9 @@ fn text_the_standard_fonts_cant_draw_stays_in_the_editor() { /// is left blocking Save out of sight. #[test] fn a_kept_draft_goes_with_its_deleted_page() { + if crate::support::skip_without_glyphs("a_kept_draft_goes_with_its_deleted_page", "en", "日本語") { + return; + } let mut h = harness(); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 612.0, height: 792.0 })); h.run_steps(2); diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index e18c7bf47a..fe96bb5951 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -504,6 +504,9 @@ fn language_switch_preserves_document_and_command_ids() { assert_eq!(documents[0]["dirty"], true); let commands = ok(&mut h, &c, "ui.commands", json!({})); for code in ["ja", "zh-hans", "fr", "de", "uk", "lt", "en"] { + if crate::support::skip_without_glyphs(&format!("language_switch_preserves_document_and_command_ids ({code})"), code, "") { + continue; + } ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": code })); h.run_steps(2); let state = ok(&mut h, &c, "ui.state", json!({})); @@ -908,6 +911,9 @@ fn state_and_view_options() { #[test] fn japanese_controls_and_search_keep_command_ids() { + if crate::support::skip_without_glyphs("japanese_controls_and_search_keep_command_ids", "ja", "") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "ja"); @@ -1002,6 +1008,9 @@ fn ukrainian_preferences_and_search_keep_command_ids() { #[test] fn preferences_menu_and_shortcut_allow_switching_interface_languages() { + if crate::support::skip_without_glyphs("preferences_menu_and_shortcut_allow_switching_interface_languages", "ja", "") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); ok(&mut h, &c, "ui.click", json!({ "label": "メニュー" })); @@ -1044,6 +1053,9 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() { #[test] fn japanese_dialogs_errors_and_custom_action_names() { + if crate::support::skip_without_glyphs("japanese_dialogs_errors_and_custom_action_names", "ja", "") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "ja"})); for (dialog, label) in [ @@ -1084,6 +1096,9 @@ fn japanese_dialogs_errors_and_custom_action_names() { #[test] fn simplified_chinese_about_tabs_and_credit_controls() { + if crate::support::skip_without_glyphs("simplified_chinese_about_tabs_and_credit_controls", "zh-hans", "") { + return; + } let (mut h, c) = harness(); let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"})); @@ -1113,6 +1128,9 @@ fn simplified_chinese_about_tabs_and_credit_controls() { #[test] fn simplified_chinese_signature_prompts_and_errors_keep_document_state() { + if crate::support::skip_without_glyphs("simplified_chinese_signature_prompts_and_errors_keep_document_state", "zh-hans", "") { + return; + } let (mut h, c) = harness(); let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"})); @@ -1721,6 +1739,9 @@ fn control_quit_retains_inactive_fill_sign_typing_after_cancel() { #[test] fn control_quit_retains_blocked_content_typing_after_cancel() { + if crate::support::skip_without_glyphs("control_quit_retains_blocked_content_typing_after_cancel", "en", "Keep 世界") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.command", json!({"id":"edit.text"})); h.run_steps(3); diff --git a/crates/ui-egui/tests/it/date_format.rs b/crates/ui-egui/tests/it/date_format.rs index 0606e021de..c2dadb28d3 100644 --- a/crates/ui-egui/tests/it/date_format.rs +++ b/crates/ui-egui/tests/it/date_format.rs @@ -133,6 +133,13 @@ fn month_names_follow_the_date_language_or_else_the_interface_language() { #[test] fn a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first() { + if crate::support::skip_without_glyphs( + "a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first", + "en", + "日曜日月曜日火曜日水曜日木曜日金曜日土曜日", + ) { + return; + } let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 9bf7f70813..0895a8b5be 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -1087,6 +1087,9 @@ fn save_prompt_fits_the_smallest_window_whatever_the_name() { let size = egui::vec2(820.0, 520.0); for name in names { let start: String = name.chars().take(10).collect(); + if support::skip_without_glyphs(&format!("save_prompt_fits_the_smallest_window_whatever_the_name ({start}…)"), "en", &name) { + continue; + } let mut h = Harness::builder().with_size(size).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); @@ -3575,6 +3578,9 @@ fn an_applied_paragraph_keeps_its_edited_look_until_the_page_is_redrawn() { /// as a form field does; the editor used to close with the error and the typing was lost. #[test] fn a_paragraph_whose_edit_fails_stays_open_with_its_typing() { + if crate::support::skip_without_glyphs("a_paragraph_whose_edit_fails_stays_open_with_its_typing", "en", "Top ก") { + return; + } let bytes = source_font_fixture_with_text("BT /F1 24 Tf 20 220 Td (Top) Tj ET BT /F2 24 Tf 20 120 Td (Bottom) Tj ET"); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); diff --git a/crates/ui-egui/tests/it/pickers.rs b/crates/ui-egui/tests/it/pickers.rs index 6aa5515527..f9e46c5f2f 100644 --- a/crates/ui-egui/tests/it/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -963,6 +963,9 @@ impl Drop for AttachmentDir { #[test] fn attachment_save_preserves_bytes_metadata_and_selected_destination() { + if crate::support::skip_without_glyphs("attachment_save_preserves_bytes_metadata_and_selected_destination", "en", "user chosen 資料.bin") { + return; + } let dir = AttachmentDir::new(); let chosen = dir.0.join("user chosen 資料.bin"); let original = attachment_fixture(); diff --git a/crates/ui-egui/tests/it/support/mod.rs b/crates/ui-egui/tests/it/support/mod.rs index aa96ab20dc..174ec5cc0b 100644 --- a/crates/ui-egui/tests/it/support/mod.rs +++ b/crates/ui-egui/tests/it/support/mod.rs @@ -1,7 +1,12 @@ //! State-based waits for native preparation, which completes between UI frames, and pointer -//! input in the frames the platform delivers it in ([`input`]). +//! input in the frames the platform delivers it in ([`input`]), and the skip for tests whose text +//! the interface fonts cannot draw ([`skip_without_glyphs`]). +use std::collections::BTreeSet; + +use egui::FontId; +use egui::epaint::text::{Fonts, TextOptions}; use egui_kittest::Harness; -use pdfcraft_ui_egui::PdfCraftApp; +use pdfcraft_ui_egui::{PdfCraftApp, i18n, theme}; pub mod input; @@ -45,3 +50,35 @@ pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl h.state().toast ); } + +/// Whether the test `name` must skip because the interface fonts lack a glyph for a character it +/// draws: one in `text` or in the catalog of the interface `language`. egui 0.37 test harnesses +/// panic on a missing glyph, and Japanese, Chinese and Thai faces come only from the optional +/// craft-fonts build input or a face installed on this machine. Prints the skip line. +/// +/// The fonts are those the app installs for `language` and for the default CJK preference: a +/// switch to Chinese installs its fonts for the next frame, so the first frame in Chinese draws +/// with the fonts of the language before it. +/// +/// A build that requires craft-fonts (`CRAFT_FONTS_REQUIRED=1`, as the CI gate sets) never skips: +/// a missing glyph there is a failure. +pub fn skip_without_glyphs(name: &str, language: &str, text: &str) -> bool { + if std::env::var("CRAFT_FONTS_REQUIRED").as_deref() == Ok("1") { + return false; + } + let catalog = i18n::LANGUAGES.iter().find(|l| l.code == language).map_or("", |l| l.source); + let translations = catalog.lines().filter(|l| !l.starts_with('#')).filter_map(|l| l.split('\t').nth(2)); + let chars: BTreeSet = std::iter::once(text).chain(translations).flat_map(str::chars).filter(|c| !c.is_control()).collect(); + let cjk = theme::CjkPreference::for_language(language); + let missing = [theme::CjkPreference::Default, cjk].into_iter().find_map(|cjk| { + let defs = theme::installed_font_definitions(cjk); + let families: Vec = defs.families.keys().map(|f| FontId::new(13.0, f.clone())).collect(); + let mut fonts = Fonts::new(TextOptions::default(), defs); + chars.iter().copied().find(|c| families.iter().any(|id| !fonts.has_glyphs(id, &c.to_string()))) + }); + let Some(missing) = missing else { + return false; + }; + eprintln!("skipping {name}: no interface font draws {missing:?} (set CRAFT_FONTS_DIR to a craft-fonts checkout with a face for it)"); + true +} From 64e0868b844e0230027a493c90d6e4bdc7af086f Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:57:46 -0400 Subject: [PATCH 28/28] feat(ui): menus from craft-menu, native on macOS (#4752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): menus from craft-menu, native on macOS One menu definition (crates/ui-egui/src/menus.rs) built from the command registry drives the native macOS menu bar (craft-menu's muda backend) and an in-window egui bar on Windows, Linux and the web, which takes the place of the "Menu" button at the start of the top bar. - File, Edit, View, Sign, Window and Help, with separators and submenus. Edit gains Cut, Paste, Delete and Select All (they act as their keys do) and a Pages submenu; View gains a Tools submenu built from the All tools groups; Sign holds Fill & Sign and the certificate commands; Window lists the open documents. - Toggles are check items and the page layouts, side panels and themes radio items, with their state from the app. - File > Open Recent lists the recent files (file.open_recent with an index). - A menu click and its shortcut run the same dispatch, including the form-field deferral (#166) and macOS Cmd+W closing the window (#869). Dialogs and the palette grey the menus through craft-menu's gate. - Quit goes through craft-quit, so the unsaved-changes prompt runs. - craft-ui, craft-quit, craft-menu and craft-keymap pinned to storytold/craft-libs 70ade1fb. Menu state and dispatch follow #2610 by @hajdukste; the menu tree and the view and panel items follow #3870 by @RivanJarjes. * test(ui): the menu model, native dispatch and the in-window bar - Unit tests for the menu definition: every registry menu command is listed once with its registry shortcut; check and radio items follow the app; a dialog greys document commands and drops their clicks; the recent files and open documents are listed once each; a native menu click (through a fake craft-menu backend) runs its command once. - The kittests that opened the old "Menu" button use the menu bar, and the ones that look up a label the bar's titles share (Edit, File, Sign) skip the bar. - The in-window bar takes its titles' width, so the mode tabs, search and buttons keep their place in the top bar. - Edit menu clicks that stand for keys (Copy, Cut, Delete, Select All) reach this frame's input from `logic`, where test hosts see them too. * test(ui): the language switch test opens File in the menu bar Translated titles and rows carry their mnemonic ("ファイル(F)", "開く(O)…"). * feat(ui): the menu bars run Quit themselves, the bar shrinks to its titles, and AppKit's window list stays off craft-libs main edc41f6 (craft-libs#354): MenuBarUi runs Quit and Minimize, shrink_to_fit sizes the in-window bar, Key::parse reads the registry's arrow and minus glyphs, and mac::install_with(.., window_list(false)) keeps the Window menu to PdfCraft's own document list. * fix(ui): the menu command filter is an if, for clippy * test(ui): drop a needless borrow in the edit-tab helper * test(ui): two tests follow the menu bar The tests were wrong, not the app. The Preferences language test clicked the old Menu button; it opens Edit in the menu bar and its Preferences row. The prepare Actions test counted the Edit labels: the narrower menu bar leaves room for the Edit tab, so the rows' Edit buttons come third and fourth. --- Cargo.lock | 49 +- Cargo.toml | 7 +- apps/pdfcraft/Cargo.toml | 3 - apps/pdfcraft/src/main.rs | 9 +- crates/ui-egui/Cargo.toml | 9 +- crates/ui-egui/src/chrome.rs | 129 +--- crates/ui-egui/src/commands.rs | 91 +-- crates/ui-egui/src/lib.rs | 25 +- crates/ui-egui/src/menus.rs | 791 ++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 4 - crates/ui-egui/tests/it/commands.rs | 52 +- crates/ui-egui/tests/it/control.rs | 33 +- crates/ui-egui/tests/it/editing.rs | 11 +- crates/ui-egui/tests/it/marks.rs | 3 +- crates/ui-egui/tests/it/prepare.rs | 10 +- crates/ui-egui/tests/it/signing.rs | 17 +- crates/ui-egui/tests/it/support/mod.rs | 21 + crates/ui-egui/tests/it/text_select.rs | 15 +- crates/ui-egui/tests/it/theme.rs | 7 +- crates/ui-egui/tests/it/trust_signer.rs | 3 +- crates/ui-egui/tests/it/ui.rs | 10 +- crates/ui-egui/tests/it/view.rs | 18 +- 22 files changed, 990 insertions(+), 327 deletions(-) create mode 100644 crates/ui-egui/src/menus.rs diff --git a/Cargo.lock b/Cargo.lock index 54be313cc6..3c713ba5cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,7 +229,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -1171,6 +1171,29 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +[[package]] +name = "craft-keymap" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +dependencies = [ + "egui", + "serde", + "serde_json", +] + +[[package]] +name = "craft-menu" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +dependencies = [ + "craft-keymap", + "craft-quit", + "egui", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", +] + [[package]] name = "craft-print" version = "0.1.0" @@ -1187,11 +1210,10 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" dependencies = [ "dispatch2", "egui", - "muda", "objc2 0.6.5", "objc2-app-kit 0.3.2", ] @@ -1199,7 +1221,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" dependencies = [ "egui", "log", @@ -1948,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3941,7 +3963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4035,7 +4057,6 @@ name = "pdfcraft" version = "0.14.0" dependencies = [ "chrono", - "craft-quit", "craft-update", "eframe", "egui", @@ -4469,6 +4490,8 @@ dependencies = [ "block2 0.6.2", "bytemuck", "chrono", + "craft-keymap", + "craft-menu", "craft-quit", "craft-ui", "craft-update", @@ -5071,7 +5094,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5592,7 +5615,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5662,7 +5685,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6546,10 +6569,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7661,7 +7684,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4d1d5e5f6c..8eb38b29af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,9 +13,12 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. -craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } +# One menu definition for the native macOS menu bar and the in-window bar elsewhere. +craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } +craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", default-features = false } # Shared system spooler; document composition remains in pdfcraft-print. craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } # Whether a path stored in a document may be opened (GoToR link targets). diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 15ec5d6f68..966af354d1 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -29,9 +29,6 @@ chrono = { workspace = true } # crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids) # behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" -# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the -# window to close instead of winit's, which ends the process without asking. -craft-quit = { workspace = true, features = ["menu"] } [target.'cfg(target_os = "linux")'.dependencies] # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index ab6ebf830c..ee8ae152cc 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -429,10 +429,11 @@ fn app_creator<'a>( #[cfg(target_os = "macos")] { app.os_events = Some(apple_events.connect(&cc.egui_ctx)); - // winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This - // menu's Quit closes the window instead, so `guard_quit` asks, as for the close button. - if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") { - log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}"); + // The native menu bar. Its Quit (⌘Q) closes the window rather than ending the process as + // winit's does (#1136), so `guard_quit` asks about unsaved changes, as for the close + // button. + if !app.install_native_menu(&cc.egui_ctx) { + log::warn!("couldn't install the macOS menu bar; the menus stay in the window"); } } if let Some(server) = instance { diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 5279f56248..804b5ea22a 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,6 +11,12 @@ workspace = true [dependencies] craft-ui = { workspace = true } +# The menus: native on macOS, an egui bar elsewhere, from one definition (menus.rs). +craft-menu = { workspace = true } +craft-keymap = { workspace = true } +# Quit from the menus asks the window to close, so the unsaved-changes check runs; on macOS a window +# close also quits through `terminate:` (the Touch Bar crash, #1136). +craft-quit = { workspace = true, features = ["egui"] } pdfcraft-fonts = { workspace = true } pdfcraft-engine = { workspace = true } pdfcraft-cos = { workspace = true } @@ -55,9 +61,6 @@ pollster = "1.0" fontconfig-parser = "0.5.8" [target.'cfg(target_os = "macos")'.dependencies] -# macOS: a window close quits through `terminate:` (the Touch Bar crash, #1136); elsewhere a no-op. -# The egui feature binds `on_close_request` to the app's egui Context, so it stays macOS-only. -craft-quit = { workspace = true, features = ["egui"] } # Safe AppKit calls for drag coordinates and repairing an existing per-file PDFCraft binding. # The Objective-C wrappers and block support are already in the tree through arboard and rfd. objc2 = "0.6" diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index b977112460..b81ac2d58c 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -311,25 +311,6 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } -/// Both theme entry points use the same choices and command path. -fn theme_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { - for (preference, command, label) in [ - (ThemePreference::System, "view.theme.system", "Use system setting"), - (ThemePreference::Light, "view.theme.light", "Light gray"), - (ThemePreference::Dark, "view.theme.dark", "Dark gray"), - ] { - if ui.add(widgets::radio(app.theme_preference == preference, tl!(label))).clicked() { - app.execute(command); - ui.close(); - } - } - ui.separator(); - if ui.button(tl!("Appearance settings…")).clicked() { - app.dialog = Some(Dialog::Preferences); - ui.close(); - } -} - /// The right-click menu on the title bar's theme button. fn appearance_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if ui.button(tl!("Appearance settings…")).clicked() { @@ -497,10 +478,13 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .show(ui, |ui| { ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - main_menu(app, ui); - ui.add_space(6.0); - ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider)); - ui.add_space(10.0); + // The menus, unless the native macOS menu bar has them. + if app.native_menu.is_none() { + app.menu_bar_ui(ui); + ui.add_space(6.0); + ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider)); + ui.add_space(10.0); + } // Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool // in use. Read mode (the panes hidden) stays a View command and a default workspace. for (mode, label) in [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] { @@ -540,103 +524,6 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } -/// Keep menus usable when their contents are taller than the window. -pub(crate) fn scroll_menu(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) { - let height = (ui.ctx().content_rect().height() - 24.0).max(40.0); - egui::ScrollArea::vertical().max_height(height).auto_shrink([true, true]).show(ui, add); -} - -fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - let resp = widgets::ghost_button(ui, "panel-left", tl!("Menu")); - egui::Popup::menu(&resp).show(|ui| { - scroll_menu(ui, |ui| { - ui.set_min_width(230.0); - ui.menu_button(tl!("File"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "File"))); - ui.menu_button(tl!("Edit"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Edit"))); - ui.menu_button(tl!("Pages"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Pages"))); - ui.menu_button(tl!("View"), |ui| { - scroll_menu(ui, |ui| { - if let Some(i) = app.active { - let v = &mut app.views[i]; - let ctx = ui.ctx().clone(); - let key = |s| crate::commands::shortcut_label(&ctx, s); - use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT}; - ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small()); - if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() { - v.set_zoom(1.0); - } - if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() { - v.fit = Fit::Page; - } - if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() { - v.fit = Fit::Width; - } - if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() { - v.fit = Fit::Height; - v.goto = Some((v.current, 0.0)); - } - if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() { - ui.close(); - app.execute("view.fit_visible"); - return; - } - if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() { - v.zoom_step(true); - } - if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() { - v.zoom_step(false); - } - if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() { - v.rotate_view(true); - } - if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() { - v.rotate_view(false); - } - ui.separator(); - ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small()); - if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() { - v.view_history(false); - } - if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() { - v.view_history(true); - } - ui.separator(); - if let Some(id) = page_display_menu(ui, &app.views[i]) { - app.execute(id); - } - ui.separator(); - } - crate::commands::registry_menu(app, ui, "View"); - ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui)); - ui.menu_button(tl!("Side panels"), |ui| { - scroll_menu(ui, |ui| { - for (p, label) in [ - (RightPanel::Comments, tl!("Comments")), - (RightPanel::Bookmarks, tl!("Bookmarks")), - (RightPanel::Pages, tl!("Pages")), - (RightPanel::Fields, tl!("Fields")), - (RightPanel::Layers, tl!("Layers")), - (RightPanel::Attachments, tl!("Attachments")), - (RightPanel::Signatures, tl!("Signatures")), - (RightPanel::Accessibility, tl!("Accessibility Checker")), - (RightPanel::Tags, tl!("Tags")), - (RightPanel::Search, tl!("Search")), - (RightPanel::Compare, tl!("Compare")), - ] { - if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() { - app.choose_right_panel(Some(p)); - } - } - }) - }); - }) - }); - ui.menu_button(tl!("Help"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Help"))); - }) - }); -} - /// Draw the right tool rail with page navigation, history, zoom, rotation and page-display controls for the /// active view. pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { @@ -822,7 +709,7 @@ fn page_position(current: usize, count: usize, label: &str) -> String { if label.is_empty() || label == number || current >= count { count.to_string() } else { format!("{number}/{count}") } } -/// View ▸ Page display and the rail's Page display menu: the layouts and the cover page. +/// The rail's Page display menu: the layouts and the cover page (also View ▸ Page display). /// Returns the command a click asks for, for the caller to run. fn page_display_menu(ui: &mut egui::Ui, view: &DocView) -> Option<&'static str> { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index a27a25ff82..26fad6f77c 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -10,7 +10,6 @@ use pdfcraft_engine::commands::{self, COMMANDS, CommandSpec, Shortcut}; use crate::{ Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, SaveTarget, theme::{ThemeKind, ThemePreference}, - widgets, }; /// Keys the document view handles itself (`canvas::shortcuts`), for the View menu, tooltips and @@ -724,11 +723,6 @@ impl PdfCraftApp { // Buttons also own keyboard focus for Tab/Enter activation, but they must not // suppress document shortcuts. Text editors retain their own undo/redo handling. let typing = ctx.text_edit_focused(); - // A form field's editor is open on the page: its text isn't in the document until - // committed. (Not egui's keyboard focus: an Escape in this frame has already cleared that, - // while the field has yet to see the Escape and discard its draft.) - let active = self.active_ids(); - let form_typing = active.and_then(|(i, _)| self.views.get(i)).is_some_and(|v| v.forms.focus.is_some()); let mut specs: Vec<&CommandSpec> = COMMANDS.iter().filter(|c| c.shortcut.is_some()).collect(); specs.sort_by_key(|c| std::cmp::Reverse(c.shortcut.map(|s| s.modifier_count()).unwrap_or(0))); for spec in specs { @@ -752,23 +746,29 @@ impl PdfCraftApp { m |= Modifiers::CTRL; } if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(m, key))) { - if form_typing { - // A form field has the keyboard: let it take this frame's typing (and - // Escape) first, so ⌘S saves what's on screen (#166). Runs next frame, for - // this document only. - self.deferred_commands.push((spec.id, active.map(|(_, id)| id))); - ctx.request_repaint(); - } else if spec.id == "file.close" && active.is_none() && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") { - // macOS ⌘W closes the last window when no document tabs remain (#869). - // Route through the normal viewport-close path (and its quit guard), - // not the disabled document-only `file.close` command. - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } else { - self.execute(spec.id); - } + self.dispatch_command(ctx, spec.id); } } } + + /// Run a registry command from its shortcut or a menu, which share this path. + pub(crate) fn dispatch_command(&mut self, ctx: &egui::Context, id: &'static str) { + let active = self.active_ids(); + let form_typing = active.and_then(|(i, _)| self.views.get(i)).is_some_and(|v| v.forms.focus.is_some()); + if form_typing { + // A form field has the keyboard: let it take this frame's typing (and Escape) first, + // so ⌘S saves what's on screen (#166). Runs next frame, for this document only. + self.deferred_commands.push((id, active.map(|(_, id)| id))); + ctx.request_repaint(); + } else if id == "file.close" && active.is_none() && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") { + // macOS ⌘W closes the last window when no document tabs remain (#869). Route through + // the normal viewport-close path (and its quit guard), not the disabled document-only + // `file.close` command. + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } else { + self.execute(id); + } + } } /// The tab after `current` of `count` (before it when `forward` is false), wrapping around. From @@ -789,57 +789,6 @@ fn tab_after(current: Option, count: usize, forward: bool) -> Option = None; - let mut clear = false; - ui.menu_button(label, |ui| { - crate::chrome::scroll_menu(ui, |ui| { - for r in &app.recent { - if ui.button(&r.name).on_hover_text(&r.path).clicked() { - open = Some(r.path.clone()); - ui.close(); - } - } - ui.separator(); - if ui.button(tl!("Clear Recent Files")).clicked() { - clear = true; - ui.close(); - } - }) - }); - if let Some(p) = open { - app.open_recent(&p); - ui.close(); - } - if clear { - app.execute("file.clear_recent"); - ui.close(); - } - continue; - } - let shortcut = spec.shortcut.or((spec.id == "edit.copy").then_some(COPY)).map(|s| shortcut_label(ui.ctx(), s)).unwrap_or_default(); - let enabled = app.command_enabled(spec); - let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(shortcut)); - if resp.clicked() { - app.execute(spec.id); - ui.close(); - } - } - let _ = widgets::menu_item; -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79f46bee7b..3446bcd26e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -63,6 +63,7 @@ mod js_ui; mod macos_association; mod marks_ui; mod measure_ui; +mod menus; mod ocr_ui; mod optimize_ui; mod search_ui; @@ -927,6 +928,10 @@ pub struct PdfCraftApp { /// Shortcuts pressed while a text field had the keyboard, run on the next frame (see /// `registry_shortcuts`). deferred_commands: Vec<(&'static str, Option)>, + /// The native menu bar (macOS), while it is installed; the in-window bar shows otherwise. + pub(crate) native_menu: Option, + /// Input a menu click stands for (⌘C, Delete…), added to the next frame's input in `logic`. + pub(crate) menu_input: Vec, /// The dialog seen at the last check, and a counter bumped whenever it changes (see /// [`Self::dialog_epoch`]). dialog_seen: Option, @@ -1261,6 +1266,8 @@ impl PdfCraftApp { last_autosave: None, allow_quit: false, deferred_commands: Vec::new(), + native_menu: None, + menu_input: Vec::new(), dialog_seen: None, dialog_epoch: 0, ctx: None, @@ -3291,7 +3298,12 @@ impl PdfCraftApp { { return; } - self.registry_shortcuts(ctx); + // The in-window menu bar holds the keys while one of its menus is open or it has the + // keyboard (Alt, F10); a command shortcut leaves it and runs. + craft_menu::bar::leave_for_shortcut(ctx); + if !craft_menu::bar::is_open(ctx) && !craft_menu::bar::is_focused(ctx) { + self.registry_shortcuts(ctx); + } // (Esc closes a Combine file shown large first.) if self.full_screen && self.combine_tab.preview.is_none() && ctx.input(|i| i.key_pressed(Key::Escape)) { self.set_full_screen(ctx, false); @@ -3499,6 +3511,10 @@ impl PdfCraftApp { } // Before egui reads this frame's wheel input. self.scroll_speed.apply(ctx, raw); + // The native menu's key equivalents. + if let Some(native) = self.native_menu.as_mut() { + native.raw_input(raw); + } } } @@ -3670,6 +3686,12 @@ impl eframe::App for PdfCraftApp { self.poll_print(); // Shortcuts deferred last frame: the text field has taken that frame's typing since. let deferred = std::mem::take(&mut self.deferred_commands); + self.native_menu_clicks(ctx); + // The keys a menu click stands for (⌘C, Delete…), before anything reads this frame's keys. + if !self.menu_input.is_empty() { + let events = std::mem::take(&mut self.menu_input); + ctx.input_mut(|i| i.events.extend(events)); + } self.shortcuts(ctx); // Scrolling and group-drag previews are transient: never resume after changing tabs, // opening a modal/palette, or returning to a window that lost focus. @@ -3700,6 +3722,7 @@ impl eframe::App for PdfCraftApp { self.process_picked(); // Last, after everything that can open or answer the "save changes?" question. self.attend_save_prompt(ctx); + self.sync_native_menu(ctx); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs new file mode 100644 index 0000000000..786840b650 --- /dev/null +++ b/crates/ui-egui/src/menus.rs @@ -0,0 +1,791 @@ +//! The menus: one definition ([`PdfCraftApp::menu_bar`]) for the native macOS menu bar and the +//! in-window bar on Windows, Linux and the web, laid out for each platform by craft-menu. +//! +//! Items run registry commands (`pdfcraft_engine::commands`) by id, with the registry's label and +//! shortcut, so a menu click and its shortcut run the same command. A few items have menu-only +//! ids: the zoom keys the document view handles itself, the side panel choices, the clipboard +//! keys, the recent files and the open documents. [`PdfCraftApp::run_menu_command`] runs every +//! one of them, once per click, whichever bar it came from. + +use craft_keymap::{Key, Mods}; +use craft_menu::{ + Builtin, Chord, CommandRef, Document, Gate, Item, ItemRole, ItemState, Labeled, LayoutOptions, Menu, MenuBar, MenuRole, Node, Target, +}; +use pdfcraft_engine::catalog::{Availability, TOOL_GROUPS}; +use pdfcraft_engine::commands::{self, Shortcut}; + +use crate::canvas::{Fit, PageLayout}; +use crate::theme::ThemePreference; +use crate::{PdfCraftApp, RightPanel}; + +/// The app's name in the menus the layouts write (About, Hide, Quit). +pub(crate) const APP_NAME: &str = "PdfCraft"; + +/// The top bar's width after the menus: the mode tabs, the search box and the buttons. +const ROW_AFTER_MENUS: f32 = 720.0; +/// The narrowest the menu bar gets: one "Menu" title. +const MIN_MENUS_WIDTH: f32 = 80.0; + +/// Menu-only commands. The registry's commands keep their own ids. +pub(crate) const CUT: &str = "edit.cut"; +pub(crate) const PASTE: &str = "edit.paste"; +pub(crate) const DELETE: &str = "edit.delete"; +pub(crate) const SELECT_ALL: &str = "edit.select_all"; +pub(crate) const NO_RECENT: &str = "file.no_recent"; +/// An open document in the Window menu, `{"index": n}` in the tab strip. +pub(crate) const DOCUMENT: &str = "window.document"; +/// A side panel, `{"panel": name}` ([`PANELS`]). +pub(crate) const PANEL: &str = "view.panel"; +const ACTUAL_SIZE: &str = "view.zoom.actual_size"; +const PAGE_LEVEL: &str = "view.zoom.page_level"; +const FIT_WIDTH: &str = "view.zoom.fit_width"; +const FIT_HEIGHT: &str = "view.zoom.fit_height"; +const ZOOM_IN: &str = "view.zoom.in"; +const ZOOM_OUT: &str = "view.zoom.out"; +const ROTATE_CW: &str = "view.rotate.cw"; +const ROTATE_CCW: &str = "view.rotate.ccw"; + +/// The menu-only items: id, English label and the shortcut shown (the keys stay with the view). +const EXTRA: &[(&str, &str, Option)] = &[ + (CUT, "Cut", Some(Shortcut::cmd("X"))), + (PASTE, "Paste", Some(Shortcut::cmd("V"))), + (DELETE, "Delete", Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "Delete" })), + (SELECT_ALL, "Select all", Some(crate::commands::SELECT_ALL)), + (NO_RECENT, "No recent files", None), + (ACTUAL_SIZE, "Actual size", Some(crate::commands::ACTUAL_SIZE)), + (PAGE_LEVEL, "Zoom to page level", Some(crate::commands::PAGE_LEVEL)), + (FIT_WIDTH, "Fit to width", Some(crate::commands::FIT_WIDTH)), + (FIT_HEIGHT, "Fit to height", None), + (ZOOM_IN, "Zoom in", Some(crate::commands::ZOOM_IN)), + (ZOOM_OUT, "Zoom out", Some(crate::commands::ZOOM_OUT)), + (ROTATE_CW, "Rotate view clockwise", Some(crate::commands::ROTATE_CW)), + (ROTATE_CCW, "Rotate view counterclockwise", Some(crate::commands::ROTATE_CCW)), +]; + +/// View ▸ Side panels: the panel, its id in [`PANEL`] commands and its English label. +const PANELS: &[(RightPanel, &str, &str)] = &[ + (RightPanel::Comments, "comments", "Comments"), + (RightPanel::Bookmarks, "bookmarks", "Bookmarks"), + (RightPanel::Pages, "pages", "Pages"), + (RightPanel::Fields, "fields", "Fields"), + (RightPanel::Layers, "layers", "Layers"), + (RightPanel::Attachments, "attachments", "Attachments"), + (RightPanel::Signatures, "signatures", "Signatures"), + (RightPanel::Accessibility, "accessibility", "Accessibility Checker"), + (RightPanel::Tags, "tags", "Tags"), + (RightPanel::Search, "search", "Search"), + (RightPanel::Compare, "compare", "Compare"), +]; + +/// The English titles of the menus and submenus, for the bar's mnemonics in languages whose +/// labels have no ASCII letter. +const TITLES: &[&str] = &[ + "File", + "Edit", + "View", + "Pages", + "Sign", + "Window", + "Help", + "Open Recent", + "Create", + "Export", + "Protect", + "Zoom", + "Rotate view", + "Page navigation", + "Page display", + "Side panels", + "Display theme", + "Tools", + "Fill & Sign", +]; + +/// Separator. +const SEP: &str = "---"; + +const FILE: &[&str] = &[ + "create.blank", + "file.open", + "file.open_recent", + "file.pin_folder", + SEP, + "page.combine", + SEP, + "file.save", + "file.save_as", + "file.revert", + SEP, + "file.close", + "file.close_all", + SEP, + "optimize.reduce", + "optimize.advanced", + SEP, + "file.properties", + "file.word_count", + SEP, + "print.dialog", +]; +const CREATE: &[&str] = &["create.file", "create.multiple", "create.multiple_separate", "create.images", "create.clipboard"]; +const EXPORT: &[&str] = &["export.docx", "export.pptx", "export.rtf", "export.html", "export.text", SEP, "export.image", "export.all_images"]; +const PROTECT: &[&str] = &["protect.password", "protect.remove", "protect.unlock", SEP, "protect.properties"]; +const EDIT: &[&str] = &[ + "edit.undo", + "edit.redo", + SEP, + CUT, + "edit.copy", + PASTE, + DELETE, + SEP, + SELECT_ALL, + SEP, + "edit.find", + "edit.advanced_search", + SEP, + "edit.column_select", + "edit.snapshot", + SEP, + "edit.attach", + "form.clear", + SEP, + "app.preferences", +]; +const ZOOM: &[&str] = &[ACTUAL_SIZE, PAGE_LEVEL, FIT_WIDTH, FIT_HEIGHT, "view.fit_visible", SEP, ZOOM_IN, ZOOM_OUT, SEP, "view.marquee_zoom"]; +const NAVIGATION: &[&str] = &["view.focus_page_input", SEP, "view.previous_view", "view.next_view"]; +const DISPLAY: &[&str] = &[ + "view.layout.single", + "view.layout.two_up", + SEP, + "view.continuous", + "view.page_gaps", + "view.layout.cover", + SEP, + "view.fit_width_scrolling", + "view.fit_one_page", +]; +const THEMES: &[&str] = &["view.theme.system", "view.theme.light", "view.theme.dark"]; +const PANES: &[&str] = &["view.side_panel", "view.tools_pane"]; +const PANE_COMMANDS: &[&str] = &["comment.list", "form.fields", "ui.dock.reset"]; +const MODES: &[&str] = &["view.full_screen", "view.read_mode"]; +const PAGES: &[&str] = &[ + "page.organize", + SEP, + "page.insert_blank", + "page.insert", + "page.replace", + "page.extract", + "page.split", + "page.duplicate", + "page.delete", + SEP, + "page.rotate", + "page.rotate_ccw", + "page.rotate_dialog", + "page.reverse", + SEP, + "page.crop", + "page.boxes", + "page.number", + SEP, + "bookmark.add", + "bookmark.from_structure", +]; +/// Sign ▸ Fill & Sign, with the short labels of the Fill & Sign toolbar. +const FILL: &[(&str, &str)] = &[ + ("sign.fill.text", "Add text"), + ("sign.fill.check", "Checkmark"), + ("sign.fill.cross", "Cross"), + ("sign.fill.dot", "Dot"), + ("sign.fill.line", "Line"), + ("sign.fill.date", "Date"), + ("sign.fill.signature", "Signature"), + ("sign.fill.initials", "Initials"), +]; +const SIGN: &[&str] = &[ + SEP, + "sign.fill.signature.change", + "sign.fill.initials.change", + "sign.fill.signature.remove", + "sign.fill.initials.remove", + SEP, + "sign.digital", + "sign.certify", + "sign.certify_invisible", + "form.add.signature", + SEP, + "sign.validate", + "sign.panel", +]; +const WINDOW: &[&str] = &["view.next_tab", "view.previous_tab"]; +const HELP: &[&str] = + &["help.shortcuts", SEP, "help.app_page", "help.github", "help.website", "help.discord", SEP, "help.check_updates", SEP, "help.about"]; + +/// A registry shortcut as a craft-keymap chord: `command` is ⌘ (Ctrl elsewhere) and `mac_ctrl` +/// the Control key on macOS. `None` for a key craft-keymap doesn't name. +pub(crate) fn chord(s: Shortcut) -> Option { + let key = Key::parse(s.key)?; + Some(Chord::new(Mods { cmd: s.command, ctrl: s.mac_ctrl, alt: false, shift: s.shift }, key)) +} + +fn extra(id: &str) -> Option<&'static (&'static str, &'static str, Option)> { + EXTRA.iter().find(|(e, ..)| *e == id) +} + +/// The English label of an item's command, for the bar's mnemonics. +fn english_label(command: &CommandRef) -> Option { + if let Some(spec) = commands::command(&command.id) { + return Some(spec.label.to_owned()); + } + if let Some((_, label, _)) = extra(&command.id) { + return Some((*label).to_owned()); + } + FILL.iter().find(|(id, _)| *id == command.id).map(|(_, label)| (*label).to_owned()) +} + +fn english(labeled: Labeled<'_>) -> Option { + match labeled { + Labeled::Command(command) => english_label(command), + Labeled::Title(shown) => TITLES.iter().find(|t| tl!(t) == shown).map(|t| (*t).to_owned()), + } +} + +fn translate(english: &'static str) -> String { + tl!(english).to_owned() +} + +/// The layouts' own labels (About, Quit, Window…) through the app's translations. +pub(crate) fn layout_options() -> LayoutOptions<'static> { + LayoutOptions { label: &translate, ..LayoutOptions::new(APP_NAME) } +} + +impl PdfCraftApp { + /// The app's menus for this frame, before the platform layout: every item's label, shortcut, + /// enabled and checked state as the app is now. A modal dialog doesn't grey anything here; + /// that is the [`Gate`]'s job ([`PdfCraftApp::menu_gate`]). + pub fn menu_bar(&self, ctx: &egui::Context) -> MenuBar { + let mut used: Vec<&str> = Vec::new(); + let list = |ids: &[&'static str], used: &mut Vec<&'static str>| -> Vec { + ids.iter() + .map(|&id| { + if id == SEP { + return Node::Separator; + } + used.push(id); + self.menu_item(id, None) + }) + .collect() + }; + let file = { + let mut nodes = Vec::new(); + for node in list(FILE, &mut used) { + // Open Recent is the live recent list; Create, Export and Protect follow Combine, + // Optimize and Close. + let recent = matches!(&node, Node::Item(it) if it.command.id == "file.open_recent"); + if recent { + nodes.push(self.recent_menu()); + continue; + } + let after = match &node { + Node::Item(it) => it.command.id.clone(), + _ => String::new(), + }; + nodes.push(node); + match after.as_str() { + "page.combine" => nodes.push(Node::submenu(tl!("Create"), list(CREATE, &mut used))), + "optimize.advanced" => { + nodes.push(Node::submenu(tl!("Export"), list(EXPORT, &mut used))); + nodes.push(Node::Separator); + nodes.push(Node::submenu(tl!("Protect"), list(PROTECT, &mut used))); + } + _ => {} + } + } + nodes + }; + // Edit ▸ Pages follows Select All. + let mut edit = Vec::new(); + for node in list(EDIT, &mut used) { + let select_all = matches!(&node, Node::Item(it) if it.command.id == SELECT_ALL); + edit.push(node); + if select_all { + edit.push(Node::submenu(tl!("Pages"), list(PAGES, &mut used))); + } + } + let mut sign = vec![Node::submenu( + tl!("Fill & Sign"), + FILL.iter() + .map(|&(id, label)| { + used.push(id); + self.menu_item(id, Some(tl!(label))) + }) + .collect(), + )]; + sign.extend(list(SIGN, &mut used)); + let window = list(WINDOW, &mut used); + let help = list(HELP, &mut used); + let mut view = vec![ + Node::submenu(tl!("Zoom"), list(ZOOM, &mut used)), + Node::submenu(tl!("Rotate view"), list(&[ROTATE_CW, ROTATE_CCW], &mut used)), + Node::submenu(tl!("Page navigation"), list(NAVIGATION, &mut used)), + Node::submenu(tl!("Page display"), list(DISPLAY, &mut used)), + Node::Separator, + ]; + view.extend(list(PANES, &mut used)); + view.push(Node::submenu( + tl!("Side panels"), + PANELS + .iter() + .map(|(_, name, label)| { + Item::new(CommandRef::with_params(PANEL, serde_json::json!({ "panel": name })), tl!(label)).radio(false).into() + }) + .collect(), + )); + view.extend(list(PANE_COMMANDS, &mut used)); + view.push(Node::Separator); + view.extend(list(MODES, &mut used)); + view.push(Node::submenu(tl!("Display theme"), list(THEMES, &mut used))); + view.push(Node::Separator); + view.push(Node::submenu(tl!("Tools"), self.tools_menu(&used))); + view.extend(list(&["view.palette"], &mut used)); + let mut bar = MenuBar { + menus: vec![ + Menu::new(tl!("File"), craft_menu::tidy(file)).role(MenuRole::File), + Menu::new(tl!("Edit"), craft_menu::tidy(edit)).role(MenuRole::Edit), + Menu::new(tl!("View"), craft_menu::tidy(view)).role(MenuRole::View), + Menu::new(tl!("Sign"), craft_menu::tidy(sign)), + Menu::new(tl!("Window"), window).role(MenuRole::Window), + Menu::new(tl!("Help"), craft_menu::tidy(help)).role(MenuRole::Help), + ], + documents: self.menu_documents(), + }; + bar.tag_item(&CommandRef::new("help.about"), ItemRole::About); + bar.tag_item(&CommandRef::new("app.preferences"), ItemRole::Settings); + craft_menu::apply_state(&mut bar, |command| self.menu_state(ctx, command)); + bar + } + + /// One item: a registry command with its live label (Undo names the edit) and shortcut, or a + /// menu-only one. Radio and check items get their kind here, their state from + /// [`PdfCraftApp::menu_state`]. + fn menu_item(&self, id: &'static str, label: Option<&str>) -> Node { + let (english, shortcut) = match commands::command(id) { + Some(spec) => { + let current = commands::current_label(spec, &self.session, self.active_ids().map(|(_, doc)| doc)); + (crate::i18n::menu_label(id, ¤t), spec.shortcut.or((id == "edit.copy").then_some(crate::commands::COPY))) + } + None => match extra(id) { + Some((_, label, shortcut)) => (tl!(label).to_owned(), *shortcut), + None => (id.to_owned(), None), + }, + }; + let mut item = Item::new(id, label.map_or(english, str::to_owned)); + if let Some(c) = shortcut.and_then(chord) { + item = item.shortcut(c); + } + if matches!(id, "view.layout.single" | "view.layout.two_up") || THEMES.contains(&id) { + item = item.radio(false); + } + item.into() + } + + /// File ▸ Open Recent: the recent files, newest first, then Clear Recent Files. + fn recent_menu(&self) -> Node { + let mut items: Vec = self + .recent + .iter() + .enumerate() + .map(|(index, r)| Item::new(CommandRef::with_params("file.open_recent", serde_json::json!({ "index": index })), r.name.clone()).into()) + .collect(); + if items.is_empty() { + items.push(self.menu_item(NO_RECENT, None)); + } else { + items.push(Node::Separator); + items.push(self.menu_item("file.clear_recent", Some(tl!("Clear Recent Files")))); + } + let label = commands::command("file.open_recent").map_or("Open Recent", |c| c.label); + Node::submenu(crate::i18n::menu_label("file.open_recent", label), items) + } + + /// View ▸ Tools: the All tools groups, each a submenu of its ready tools that no other menu + /// lists, sections apart. + fn tools_menu(&self, used: &[&str]) -> Vec { + TOOL_GROUPS + .iter() + .filter(|g| g.availability == Availability::Ready) + .map(|group| { + let mut nodes = Vec::new(); + let mut seen: Vec<&str> = Vec::new(); + for section in group.sections { + nodes.push(Node::Separator); + for tool in section.items.iter().filter(|t| t.availability == Availability::Ready) { + let actions: &[&'static str] = if tool.actions.is_empty() { std::slice::from_ref(&tool.command) } else { tool.actions }; + for &id in actions { + if commands::command(id).is_none() || used.contains(&id) || seen.contains(&id) { + continue; + } + seen.push(id); + let label = if tool.actions.is_empty() { Some(tl!(tool.label)) } else { None }; + nodes.push(self.menu_item(id, label)); + } + } + } + Node::submenu(tl!(group.label), nodes) + }) + .collect() + } + + /// The open documents, in tab order, for the Window menu. + fn menu_documents(&self) -> Vec { + (0..self.views.len()) + .filter_map(|index| { + let name = crate::canvas::tab_label(self, index)?; + Some(Document::new(CommandRef::with_params(DOCUMENT, serde_json::json!({ "index": index })), name).active(self.active == Some(index))) + }) + .collect() + } + + /// What may run from the menus now: a dialog, a prompt or the command palette holds the + /// window. Edit's text items stay with a focused text field (craft-menu's gate). + pub fn menu_gate(&self, ctx: &egui::Context) -> Gate { + Gate { modal: self.modal_open() || self.palette_open, recording_shortcut: false, text_editing: ctx.text_edit_focused() } + } + + /// Whether a menu item's command can run now, and its checked or selected state. + pub fn menu_state(&self, ctx: &egui::Context, command: &CommandRef) -> ItemState { + let view = self.active.and_then(|i| self.views.get(i)); + let organize = view.is_some_and(|v| v.organize); + let has_doc = view.is_some(); + let enabled_id = |id: &str| commands::command(id).is_some_and(|s| self.command_enabled(s)); + let index = param_index(command); + let text = ctx.text_edit_focused() && matches!(command.id.as_str(), "edit.undo" | "edit.redo" | "edit.copy" | CUT | PASTE | SELECT_ALL); + let enabled = text + || match command.id.as_str() { + NO_RECENT => false, + "file.open_recent" => index.is_none_or(|i| i < self.recent.len()), + "file.clear_recent" => !self.recent.is_empty(), + DOCUMENT => index.is_some_and(|i| i < self.views.len()), + // macOS ⌘W closes the window when no document is open (#869). + "file.close" if !has_doc && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") => true, + "edit.copy" if organize => enabled_id("page.copy"), + "edit.copy" if view.is_some_and(|v| v.picked_image.is_some()) => { + self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.allows_copying()) + } + CUT => organize && enabled_id("page.cut"), + PASTE => organize && self.page_clipboard.is_some() && enabled_id("page.paste"), + SELECT_ALL | DELETE => has_doc, + ACTUAL_SIZE | PAGE_LEVEL | FIT_WIDTH | FIT_HEIGHT | ZOOM_IN | ZOOM_OUT | ROTATE_CW | ROTATE_CCW => has_doc, + PANEL => panel(command).is_some(), + id => enabled_id(id), + }; + let checked = match command.id.as_str() { + "view.layout.single" => Some(view.is_some_and(|v| v.layout == PageLayout::Single)), + "view.layout.two_up" => Some(view.is_some_and(|v| v.layout == PageLayout::TwoUp)), + "view.continuous" => Some(view.is_some_and(|v| v.continuous())), + "view.page_gaps" => Some(view.is_some_and(|v| v.show_page_gaps)), + "view.layout.cover" => Some(view.is_some_and(|v| v.cover)), + "view.full_screen" => Some(self.full_screen), + "view.read_mode" => Some(self.mode == crate::Mode::Read), + "view.side_panel" => Some(self.right.is_some()), + "view.tools_pane" => Some(self.left_open), + "view.theme.system" => Some(self.theme_preference == ThemePreference::System), + "view.theme.light" => Some(self.theme_preference == ThemePreference::Light), + "view.theme.dark" => Some(self.theme_preference == ThemePreference::Dark), + PANEL => Some(panel(command).is_some_and(|p| self.right == Some(p))), + _ => None, + }; + ItemState { enabled, checked } + } + + /// Run a command chosen in either menu bar. Registry commands go through + /// [`PdfCraftApp::dispatch_command`], as their shortcuts do; the clipboard items act as + /// their keys would (⌘C is a Copy event, Delete a key press), so every place that handles the + /// key handles the click. A command that can't run now (a dialog opened since the menu was + /// drawn) does nothing. + pub fn run_menu_command(&mut self, ctx: &egui::Context, command: &CommandRef) { + // Both menu bars run Quit and Minimize themselves; Hide and Hide Others are AppKit's. + if Builtin::of(command).is_some() { + return; + } + if !self.menu_gate(ctx).allows_command(command) || !self.menu_state(ctx, command).enabled { + return; + } + let index = param_index(command); + let key = |key: egui::Key, modifiers: egui::Modifiers| { + [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }) + }; + match command.id.as_str() { + "file.open_recent" if index.is_some() => { + if let Some(path) = index.and_then(|i| self.recent.get(i)).map(|r| r.path.clone()) { + self.open_recent(&path); + } + } + DOCUMENT => { + if let Some(i) = index.filter(|&i| i < self.views.len()) { + self.active = Some(i); + } + } + "edit.copy" => self.menu_input.push(egui::Event::Copy), + CUT => self.menu_input.push(egui::Event::Cut), + PASTE => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste), + SELECT_ALL => self.menu_input.extend(key(egui::Key::A, egui::Modifiers::COMMAND)), + DELETE => self.menu_input.extend(key(egui::Key::Delete, egui::Modifiers::NONE)), + PANEL => self.choose_right_panel(panel(command)), + ACTUAL_SIZE | PAGE_LEVEL | FIT_WIDTH | FIT_HEIGHT | ZOOM_IN | ZOOM_OUT | ROTATE_CW | ROTATE_CCW => { + let Some(v) = self.active.and_then(|i| self.views.get_mut(i)) else { return }; + match command.id.as_str() { + ACTUAL_SIZE => v.set_zoom(1.0), + PAGE_LEVEL => v.fit = Fit::Page, + FIT_WIDTH => v.fit = Fit::Width, + FIT_HEIGHT => { + v.fit = Fit::Height; + v.goto = Some((v.current, 0.0)); + } + ZOOM_IN => v.zoom_step(true), + ZOOM_OUT => v.zoom_step(false), + ROTATE_CW => v.rotate_view(true), + _ => v.rotate_view(false), + } + } + id => { + if let Some(spec) = commands::command(id) { + self.dispatch_command(ctx, spec.id); + } + } + } + ctx.request_repaint(); + } + + /// The menu bar drawn in the window (Windows, Linux, the web, and macOS when the native one + /// couldn't be installed), where the top bar starts. Runs the commands chosen. + pub(crate) fn menu_bar_ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let bar = craft_menu::layout(&self.menu_bar(&ctx), Target::current(), &layout_options()).bar; + let gate = self.menu_gate(&ctx); + // The mode tabs, search and buttons follow the bar in the same row: it takes its titles' + // width, less when the row is short, where the menus that don't fit move into its "⋯". + let fit = (ui.available_width() - ROW_AFTER_MENUS).max(MIN_MENUS_WIDTH); + let chosen = craft_menu::MenuBarUi::new(&bar) + .gate(gate) + .label(&translate) + .english(&english) + .id(egui::Id::unique("pdfcraft-menu-bar")) + .shrink_to_fit(fit) + .show(ui); + for command in chosen { + self.run_menu_command(&ctx, &command); + } + } + + /// Install the native menu bar (macOS), once, from the eframe app creator. `false` when + /// AppKit refused it: the in-window bar stays. + #[cfg(target_os = "macos")] + pub fn install_native_menu(&mut self, ctx: &egui::Context) -> bool { + crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.language)); + let bar = craft_menu::mac_layout(&self.menu_bar(ctx), &layout_options()).bar; + // The Window menu lists the open documents itself, so AppKit's window list stays off. + self.native_menu = craft_menu::mac::install_with(ctx, &bar, craft_menu::mac::MacOptions::default().window_list(false)); + self.native_menu.is_some() + } + + /// The native menu's clicks since the last frame, run like in-window ones. + pub(crate) fn native_menu_clicks(&mut self, ctx: &egui::Context) { + let gate = self.menu_gate(ctx); + let Some(native) = self.native_menu.as_mut() else { return }; + native.set_gate(gate); + for command in native.take_clicks(ctx) { + self.run_menu_command(ctx, &command); + } + } + + /// Bring the native menu in step with the app, after this frame's changes. + pub(crate) fn sync_native_menu(&mut self, ctx: &egui::Context) { + if self.native_menu.is_none() { + return; + } + let layout = craft_menu::mac_layout(&self.menu_bar(ctx), &layout_options()); + let gate = self.menu_gate(ctx); + if let Some(native) = self.native_menu.as_mut() { + native.set_gate(gate); + for clash in native.sync(layout) { + log::info!("menu shortcut {:?} kept by {} over {} ({})", clash.shortcut, clash.command.id, clash.system, clash.resolution); + } + } + } +} + +/// A command's `{"index": n}` parameter. +fn param_index(command: &CommandRef) -> Option { + command.params.get("index").and_then(serde_json::Value::as_u64).and_then(|i| usize::try_from(i).ok()) +} + +/// The side panel a [`PANEL`] command names. +fn panel(command: &CommandRef) -> Option { + let name = command.params.get("panel").and_then(serde_json::Value::as_str)?; + PANELS.iter().find(|(_, n, _)| *n == name).map(|(p, ..)| *p) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use craft_menu::{Backend, Dispatch, Event, ItemKind, NativeMenu}; + + use super::*; + + const PDF: &[u8] = include_bytes!("../tests/data/form.pdf"); + + fn app(docs: &[&str]) -> (PdfCraftApp, egui::Context) { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + for name in docs { + app.open_bytes(name, None, PDF.to_vec()).unwrap(); + } + (app, egui::Context::default()) + } + + fn linux(app: &PdfCraftApp, ctx: &egui::Context) -> MenuBar { + craft_menu::layout(&app.menu_bar(ctx), Target::Linux, &layout_options()).bar + } + + fn item<'b>(bar: &'b MenuBar, command: &CommandRef) -> &'b Item { + bar.find(command).unwrap_or_else(|| panic!("{} is in the menus", command.key())) + } + + #[test] + fn every_menu_command_is_listed_once_with_its_shortcut() { + let (app, ctx) = app(&["a.pdf"]); + let bar = linux(&app, &ctx); + let items = bar.items(); + let mut keys: Vec = items.iter().map(|it| it.command.key()).collect(); + keys.sort(); + let count = keys.len(); + keys.dedup(); + assert_eq!(keys.len(), count, "no command is listed twice"); + for spec in commands::COMMANDS.iter().filter(|s| s.menu.is_some()) { + if spec.id == "file.open_recent" { + continue; // A submenu of the recent files. + } + let it = item(&bar, &CommandRef::new(spec.id)); + let shortcut = spec.shortcut.or((spec.id == "edit.copy").then_some(crate::commands::COPY)); + assert_eq!(it.shortcut, shortcut.and_then(chord), "{} has its registry shortcut", spec.id); + } + // Every item runs a registry command or one of the menu's own. + for it in items { + let id = it.command.id.as_str(); + assert!( + commands::command(id).is_some() || extra(id).is_some() || [PANEL, DOCUMENT].contains(&id) || Builtin::of(&it.command).is_some(), + "{id} runs something" + ); + } + } + + #[test] + fn check_and_radio_items_follow_the_app() { + let (mut app, ctx) = app(&["a.pdf"]); + let kind = |app: &PdfCraftApp, id: &str| item(&linux(app, &ctx), &CommandRef::new(id)).kind; + assert_eq!(kind(&app, "view.layout.single"), ItemKind::Radio(true)); + assert_eq!(kind(&app, "view.layout.two_up"), ItemKind::Radio(false)); + let continuous = app.views[0].continuous(); + assert_eq!(kind(&app, "view.continuous"), ItemKind::Check(continuous)); + app.run_menu_command(&ctx, &CommandRef::new("view.layout.two_up")); + app.run_menu_command(&ctx, &CommandRef::new("view.continuous")); + assert_eq!(kind(&app, "view.layout.single"), ItemKind::Radio(false)); + assert_eq!(kind(&app, "view.layout.two_up"), ItemKind::Radio(true)); + assert_eq!(kind(&app, "view.continuous"), ItemKind::Check(!continuous)); + let left = app.left_open; + assert_eq!(kind(&app, "view.tools_pane"), ItemKind::Check(left)); + app.run_menu_command(&ctx, &CommandRef::new("view.tools_pane")); + assert_eq!(kind(&app, "view.tools_pane"), ItemKind::Check(!left)); + let bookmarks = CommandRef::with_params(PANEL, serde_json::json!({ "panel": "bookmarks" })); + app.run_menu_command(&ctx, &bookmarks); + assert_eq!(app.right, Some(RightPanel::Bookmarks)); + assert_eq!(item(&linux(&app, &ctx), &bookmarks).kind, ItemKind::Radio(true)); + assert_eq!(kind(&app, "view.side_panel"), ItemKind::Check(true)); + } + + #[test] + fn a_dialog_greys_document_commands_and_drops_their_clicks() { + let (mut app, ctx) = app(&["a.pdf"]); + let rotate = CommandRef::new("page.rotate"); + assert!(app.menu_gate(&ctx).allows_command(&rotate)); + app.dialog = Some(crate::Dialog::About); + assert!(!app.menu_gate(&ctx).allows_command(&rotate), "the gate greys the menus under a dialog"); + let doc = app.views[0].id; + let before = app.session.get(doc).unwrap().info.pages[0].rotation; + app.run_menu_command(&ctx, &rotate); + assert_eq!(app.session.get(doc).unwrap().info.pages[0].rotation, before, "a click that arrives anyway does nothing"); + // Quit still asks the window to close, which asks about unsaved changes. + assert!(app.menu_gate(&ctx).allows_command(&Builtin::Quit.command())); + } + + #[test] + fn recent_files_and_open_documents_are_listed_once_each() { + let (mut app, ctx) = app(&["a.pdf", "b.pdf"]); + app.recent = ["one.pdf", "two.pdf"].map(|n| crate::RecentFile { name: n.into(), path: format!("/nowhere/{n}"), pages: 1, size: 0 }).into(); + let bar = linux(&app, &ctx); + for (i, name) in ["one.pdf", "two.pdf"].into_iter().enumerate() { + assert_eq!(item(&bar, &CommandRef::with_params("file.open_recent", serde_json::json!({ "index": i }))).label, name); + } + let window = bar.menu(MenuRole::Window).unwrap(); + let documents: Vec<&str> = window + .children + .iter() + .filter_map(|n| match n { + Node::Item(it) if it.command.id == DOCUMENT => Some(it.label.as_str()), + _ => None, + }) + .collect(); + assert_eq!(documents, ["a.pdf", "b.pdf"], "each open document once, in tab order"); + app.run_menu_command(&ctx, &CommandRef::with_params(DOCUMENT, serde_json::json!({ "index": 0 }))); + assert_eq!(app.active, Some(0)); + // The macOS layout lists them once too. + let mac = craft_menu::mac_layout(&app.menu_bar(&ctx), &layout_options()).bar; + let listed = mac.items().iter().filter(|it| it.command.id == DOCUMENT).count(); + assert_eq!(listed, 2); + app.recent.clear(); + let bar = linux(&app, &ctx); + assert!(!item(&bar, &CommandRef::new(NO_RECENT)).enabled, "an empty list shows a greyed placeholder"); + assert!(bar.find(&CommandRef::new("file.clear_recent")).is_none()); + } + + /// A native backend that reports what the test queues, through the driver's dispatch as the + /// macOS backend does. + #[derive(Clone, Default)] + struct FakeMenu(Rc, Vec)>>); + + impl FakeMenu { + fn click(&self, command: &CommandRef) { + let mut state = self.0.borrow_mut(); + let event = state.0.as_ref().map(|d| d.chosen(command, None, false, None)); + state.1.extend(event); + } + } + + impl Backend for FakeMenu { + fn sync(&mut self, _bar: &MenuBar) {} + fn drain(&mut self) -> Vec { + std::mem::take(&mut self.0.borrow_mut().1) + } + fn attach(&mut self, dispatch: Dispatch) { + self.0.borrow_mut().0 = Some(dispatch); + } + } + + #[test] + fn a_native_menu_click_runs_its_command_once() { + let (mut app, ctx) = app(&["a.pdf"]); + let fake = FakeMenu::default(); + app.native_menu = Some(NativeMenu::new(Box::new(fake.clone()))); + let doc = app.views[0].id; + let before = app.session.get(doc).unwrap().info.pages[0].rotation; + fake.click(&CommandRef::new("page.rotate")); + let mut raw = egui::RawInput::default(); + app.prepare_raw_input(&ctx, &mut raw); + app.native_menu_clicks(&ctx); + app.native_menu_clicks(&ctx); + assert_eq!(app.session.get(doc).unwrap().info.pages[0].rotation, (before + 90) % 360, "rotated once"); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 01cab03314..23513cc973 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -213,10 +213,6 @@ pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response if elided { resp.on_hover_text(placeholder) } else { resp } } -pub fn menu_item(ui: &mut egui::Ui, label: &str, shortcut: &str) -> Response { - ui.add(egui::Button::new(label).shortcut_text(shortcut)) -} - /// Corner radius of a checkbox's box. With the widgets' radius (`Tokens::radius`, 6) egui's 14 px /// box is a circle, the shape of a radio button, and View ▸ Page display has both. const CHECKBOX_RADIUS: u8 = 3; diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index 15c23c6296..1e3c36216f 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -293,15 +293,14 @@ fn palette_arrow_keys_select_results_and_enter_runs_the_selection() { assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages[0].rotation, 270); } -/// Check that choosing Delete pages from the Pages menu reduces the document page count. +/// Check that choosing Edit ▸ Pages ▸ Delete pages in the menu bar reduces the page count once. #[test] fn the_pages_menu_comes_from_the_registry() { let mut h = harness(); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("Pages").hover(); + support::open_menu(&mut h, "Edit"); + support::menu_row(&h, "Pages").hover(); h.run_steps(3); - h.get_by_label_contains("Delete pages").click(); + support::menu_row(&h, "Delete pages").click(); h.run_steps(3); let app = h.state(); assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages.len(), 2); @@ -327,14 +326,11 @@ fn the_open_recent_menu_lists_files_and_opens_one() { } }); h.run_steps(4); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File").hover(); - h.run_steps(3); - h.get_by_label("Open Recent").hover(); + support::open_menu(&mut h, "File"); + support::menu_row(&h, "Open Recent").hover(); h.run_steps(3); let original = h.state().views[0].id; - h.get_by_label_contains("recent.pdf").click(); + support::menu_row(&h, "recent.pdf").click(); support::wait_until(&mut h, "recent document", |app| app.views.len() == 2 && app.password_prompt.is_none()); let app = h.state(); let active = app.active.unwrap(); @@ -348,10 +344,7 @@ fn the_open_recent_menu_lists_files_and_opens_one() { #[test] fn the_open_recent_menu_is_disabled_while_the_list_is_empty() { let mut h = harness(); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File").hover(); - h.run_steps(3); + support::open_menu(&mut h, "File"); assert!( h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), "Open Recent is disabled while no file has been opened" @@ -373,19 +366,13 @@ fn clear_recent_files_at_the_foot_of_open_recent_empties_the_list() { app }); h.run_steps(4); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File").hover(); + support::open_menu(&mut h, "File"); + support::menu_row(&h, "Open Recent").hover(); h.run_steps(3); - h.get_by_label("Open Recent").hover(); - h.run_steps(3); - h.get_by_label("Clear Recent Files").click(); + support::menu_row(&h, "Clear Recent Files").click(); h.run_steps(3); assert!(h.state().recent.is_empty(), "the list is empty"); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File").hover(); - h.run_steps(3); + support::open_menu(&mut h, "File"); assert!(h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), "and Open Recent is disabled"); } @@ -458,13 +445,13 @@ fn localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings( assert!(!doc.dirty); } -/// Menu ▸ View open, with egui told the app runs on `os`. +/// View ▸ Zoom open in the menu bar, with egui told the app runs on `os`. fn view_menu(os: OperatingSystem) -> Harness<'static, PdfCraftApp> { let mut h = harness(); h.ctx.set_os(os); - h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("View").hover(); + support::open_menu(&mut h, "View"); + support::menu_row(&h, "Zoom").hover(); h.run_steps(3); h } @@ -473,21 +460,14 @@ fn view_menu(os: OperatingSystem) -> Harness<'static, PdfCraftApp> { fn view_menu_and_tooltips_write_shortcuts_for_the_platform() { // On Linux, View ▸ Zoom and Page navigation showed ⌘1, ⌘[ … (hard-coded) while the // registered items under them showed Ctrl+K; tooltips showed ⌘ too. + // The menu bar writes its shortcuts for the build's platform (craft-menu tests that). let h = view_menu(OperatingSystem::Nix); - h.get_by_label("Actual size Ctrl+1"); - h.get_by_label("Zoom to page level Ctrl+0"); - h.get_by_label("Rotate view counterclockwise Ctrl+Shift+−"); - h.get_by_label("Previous view Ctrl+["); - h.get_by_label("Find tools and commands… Ctrl+K"); h.get_by_label("Zoom in (Ctrl++)"); h.get_by_label("Print (Ctrl+P)"); assert_eq!(h.query_all_by_label_contains("⌘").count(), 0, "no ⌘ outside macOS"); assert_eq!(h.query_all_by_label_contains("Fit visible").count(), 1, "View lists Fit visible once"); - h.get_by_label("Fit visible Ctrl+3"); let h = view_menu(OperatingSystem::Mac); - h.get_by_label("Actual size ⌘1"); - h.get_by_label("Fit visible ⌘3"); h.get_by_label("Rotate view clockwise (⇧⌘+)"); assert_eq!(h.query_all_by_label_contains("Ctrl+").count(), 0, "no Ctrl+ on macOS"); } diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index fe96bb5951..e2b09931ea 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -515,12 +515,16 @@ fn language_switch_preserves_document_and_command_ids() { assert_eq!(ok(&mut h, &c, "ui.commands", json!({})), commands); let lang = i18n::Lang::from_code(code).unwrap(); - h.get_by_label(i18n::tr(lang, "Menu")).click(); + // A title without an ASCII letter shows its mnemonic after it ("ファイル(F)"). + let file = i18n::tr(lang, "File"); + h.get_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(file))).click(); h.run_steps(2); - h.get_by_label(i18n::tr(lang, "File")).hover(); - h.run_steps(3); - h.get_by_label_contains(i18n::tr(lang, "Open…")); - ok(&mut h, &c, "ui.key", json!({ "key": "Escape" })); + // The mnemonic goes before an ellipsis: "開く(O)…". + let open = i18n::tr(lang, "Open…").trim_end_matches('…'); + let open_row = |n: &egui_kittest::kittest::AccessKitNode<'_>| { + n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(open)) + }; + assert!(h.query_all_by(open_row).next().is_some(), "File lists Open… in {code}"); ok(&mut h, &c, "ui.key", json!({ "key": "Escape" })); h.run_steps(2); } @@ -1013,18 +1017,17 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() { } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); - ok(&mut h, &c, "ui.click", json!({ "label": "メニュー" })); + // The menu bar's Edit menu holds Preferences. Titles and rows carry their mnemonic + // ("編集(E)", "環境設定(P)…"), so match by the start of the label. + let menu_row = |text: &'static str| { + move |n: &egui_kittest::kittest::AccessKitNode<'_>| { + n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(text)) + } + }; + h.get_by(menu_row("編集")).click(); h.run_steps(2); - // The open menu's Edit entry and the left panel's Edit tool share the visible label. Pick - // the popup entry by its id, not by its visible text. - let edits = ok(&mut h, &c, "ui.inspect", json!({ "query": "編集", "role": "Button" })); - // The tool button reports its toggle state; the popup's menu button does not. - let edit = edits["widgets"].as_array().unwrap().iter().find(|w| w.get("toggled").is_none()).expect("the Edit menu entry"); - ok(&mut h, &c, "ui.click", json!({ "id": edit["id"] })); + h.get_by(menu_row("環境設定")).click(); h.run_steps(2); - let menu = ok(&mut h, &c, "ui.inspect", json!({ "query": "環境設定…" })); - let prefs = menu["widgets"].as_array().unwrap().iter().find(|w| w["clickable"] == true).expect("Preferences menu item"); - ok(&mut h, &c, "ui.click", json!({ "id": prefs["id"] })); for (current, next, code) in [("日本語", "English", "en"), ("English", "Українська", "uk"), ("Українська", "日本語", "ja")] { let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": current })); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 0895a8b5be..e5e104d674 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -1371,13 +1371,8 @@ fn edit_menu_names_the_step_to_undo() { let mut h = harness(2, |app| { app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); }); - h.get_by_label("Menu").click(); - h.run_steps(2); - // The popup's Edit entry and the left panel's Edit tool carry the same visible label. The - // tool reports a toggle state; the menu entry does not. - h.get_all_by_label("Edit").find(|n| n.accesskit_node().toggled().is_none()).expect("the Edit menu entry").hover(); // submenus open on hover; their labels carry the arrow - h.run_steps(3); - h.get_by_label_contains("Undo Rotate page").click(); // the label includes the shortcut + support::open_menu(&mut h, "Edit"); + support::menu_row(&h, "Undo Rotate page").click(); h.run_steps(3); let app = h.state(); let doc = app.session.get(app.views[0].id).unwrap(); @@ -3706,7 +3701,7 @@ fn editing_existing_text_in_place() { /// paragraph editor and retypes it as "Chapter One", leaving the editor open. fn edit_tab_with_an_open_paragraph(h: &mut Harness<'static, PdfCraftApp>) { use pdfcraft_ui_egui::{Mode, QuickTool}; - h.get_by_label("Edit").click(); + support::outside_menus(h, "Edit").click(); h.run_steps(3); assert_eq!((h.state().mode, h.state().quick_tool), (Mode::Edit, QuickTool::EditText)); open_the_first_line(h); diff --git a/crates/ui-egui/tests/it/marks.rs b/crates/ui-egui/tests/it/marks.rs index 8861310115..38ffdffd39 100644 --- a/crates/ui-egui/tests/it/marks.rs +++ b/crates/ui-egui/tests/it/marks.rs @@ -1,5 +1,6 @@ //! Header & footer, watermark and background dialogs in the real shell (egui_kittest). +use crate::support; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_engine::MarkKind; @@ -127,7 +128,7 @@ fn backgrounds_and_watermarks_from_a_file() { let mut h = harness(); h.state_mut().execute("edit.background"); h.run_steps(2); - h.get_by_label("File").click(); + support::outside_menus(&h, "File").click(); h.run_steps(2); h.get_by_label("No file chosen"); assert!(h.query_by(|n| n.label().as_deref() == Some("OK") && n.is_disabled()).is_some(), "OK waits for a file"); diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 806fa7153e..8d565ab696 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -813,16 +813,16 @@ fn actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced() { }; open(&mut h); h.get_by_label("Run a JavaScript, then 1 more action"); - // The first Edit is the menu bar's; the rows' follow (Mouse Up, On Blur). - assert_eq!(h.get_all_by_label("Edit").count(), 3); - h.get_all_by_label("Edit").nth(1).unwrap().click(); + // The menu bar's Edit menu and the Edit tab come first; the rows' follow (Mouse Up, On Blur). + assert_eq!(h.get_all_by_label("Edit").count(), 4); + h.get_all_by_label("Edit").nth(2).unwrap().click(); h.run_steps(2); assert_eq!(h.state().field_props.as_ref().unwrap().editing, Some(FieldTrigger::MouseUp)); // While one script is open, no other Edit can overwrite it. - assert!(h.get_all_by_label("Edit").skip(1).all(|n| n.accesskit_node().is_disabled())); + assert!(h.get_all_by_label("Edit").skip(2).all(|n| n.accesskit_node().is_disabled())); h.get_by_label("Discard").click(); h.run_steps(2); - assert!(h.get_all_by_label("Edit").skip(1).all(|n| !n.accesskit_node().is_disabled())); + assert!(h.get_all_by_label("Edit").skip(2).all(|n| !n.accesskit_node().is_disabled())); // Replace the Mouse Up script with a link: the chained link stays after it. { let d = h.state_mut().field_props.as_mut().unwrap(); diff --git a/crates/ui-egui/tests/it/signing.rs b/crates/ui-egui/tests/it/signing.rs index 0050f82b19..62d97d4e58 100644 --- a/crates/ui-egui/tests/it/signing.rs +++ b/crates/ui-egui/tests/it/signing.rs @@ -1,5 +1,6 @@ //! Use a certificate in the real shell: draw a signature, create a digital ID, sign, validate. +use crate::support; use egui::{Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; @@ -128,14 +129,14 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { field(&h, "Digital ID password").click(); h.run_steps(1); field(&h, "Digital ID password").type_text("nope"); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(3); h.get_by_label("The password is incorrect."); h.state_mut().sign_draft.as_mut().unwrap().password = "secret12".into(); field(&h, "Reason").click(); h.run_steps(1); field(&h, "Reason").type_text("I approve"); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); @@ -275,7 +276,7 @@ fn certifying_without_a_visible_signature() { h.get_by_label("Certify as \"Test Signer EC\""); h.get_by_label("Permitted actions after certifying"); h.state_mut().sign_draft.as_mut().unwrap().password = "test".into(); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None); @@ -361,7 +362,7 @@ fn clicking_an_empty_signature_field_signs_it() { d.password = "test".into(); } h.run_steps(2); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap(); @@ -398,7 +399,7 @@ fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() { h.get_by_label("Fill & Sign signature").click(); h.run_steps(2); assert_eq!(h.state().sign_draft.as_ref().unwrap().graphic, SignGraphic::FillSign); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None); @@ -418,7 +419,7 @@ fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() { h.state_mut().sign_draft.as_mut().unwrap().graphic = SignGraphic::Image(image.clone()); h.run_steps(2); h.get_by_label("Choose image…"); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None); @@ -534,7 +535,7 @@ fn windows_store_identity_does_not_ask_for_a_file_password() { h.get_by_label("Sign as \"Store signer\""); assert_eq!(h.query_all_by_label("Digital ID password").count(), 0); h.get_by_label("The key is in the Windows certificate store, which may ask to allow PdfCraft to use it."); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(3); assert_eq!(h.state().dialog, Some(Dialog::Sign)); assert!(h.state().sign_draft.as_ref().unwrap().error.as_ref().unwrap().contains("Windows certificate store")); @@ -679,7 +680,7 @@ fn signing_preserves_destination_opening_attributes() { let d = h.state_mut().sign_draft.as_mut().unwrap(); (d.selected, d.step, d.password) = (Some(entry), SignStep::SignAs, "test".into()); h.run_steps(2); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); assert!(h.state().session.get(h.state().views[0].id).unwrap().signatures.iter().any(|s| s.signed)); assert_eq!(xattr::get(&dest, "com.apple.LaunchServices.OpenWith").unwrap(), Some(b"existing binding".to_vec())); diff --git a/crates/ui-egui/tests/it/support/mod.rs b/crates/ui-egui/tests/it/support/mod.rs index 174ec5cc0b..f419bb3296 100644 --- a/crates/ui-egui/tests/it/support/mod.rs +++ b/crates/ui-egui/tests/it/support/mod.rs @@ -51,6 +51,27 @@ pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl ); } +/// Open the in-window menu bar's `title` menu (File, Edit, View…). +pub fn open_menu(h: &mut Harness<'static, PdfCraftApp>, title: &str) { + use egui_kittest::kittest::Queryable; + h.get_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(title)).click(); + h.run_steps(2); +} + +/// The menu row labelled `label` in an open menu. +pub fn menu_row<'h>(h: &'h Harness<'static, PdfCraftApp>, label: &str) -> egui_kittest::Node<'h> { + use egui_kittest::kittest::Queryable; + let label = label.to_owned(); + h.get_by(move |n| n.role() == egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(label.as_str())) +} + +/// The widget labelled `label` outside the menu bar, whose titles (File, Edit, Sign…) may share it. +pub fn outside_menus<'h>(h: &'h Harness<'static, PdfCraftApp>, label: &str) -> egui_kittest::Node<'h> { + use egui_kittest::kittest::Queryable; + let label = label.to_owned(); + h.get_by(move |n| n.role() != egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(label.as_str())) +} + /// Whether the test `name` must skip because the interface fonts lack a glyph for a character it /// draws: one in `text` or in the catalog of the interface `language`. egui 0.37 test harnesses /// panic on a missing glyph, and Japanese, Chinese and Thai faces come only from the optional diff --git a/crates/ui-egui/tests/it/text_select.rs b/crates/ui-egui/tests/it/text_select.rs index 7f7358bfeb..e480f1c598 100644 --- a/crates/ui-egui/tests/it/text_select.rs +++ b/crates/ui-egui/tests/it/text_select.rs @@ -192,17 +192,14 @@ fn copied(h: &Harness<'static, PdfCraftApp>, want: &str) -> bool { /// Check that both the Edit menu and the context menu copy the selected page text. #[test] fn selected_text_copy_edit_and_context_menus() { - use egui_kittest::kittest::{NodeT, Queryable}; + use egui_kittest::kittest::Queryable; let (mut h, at) = harness(|_| {}); click(&mut h, at); click(&mut h, at); h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Hand; h.run_steps(2); // Hide floating Copy so this test can only reach menu entries. - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_all_by_label("Edit").find(|n| n.accesskit_node().toggled().is_none()).expect("the Edit menu entry").hover(); - h.run_steps(3); - h.get_by_label_contains("Copy").click(); + crate::support::open_menu(&mut h, "Edit"); + crate::support::menu_row(&h, "Copy").click(); let mut did_copy = false; for _ in 0..4 { h.step(); @@ -229,15 +226,13 @@ fn selected_text_copy_edit_and_context_menus() { #[test] fn escape_closes_an_open_menu_before_it_clears_the_selection() { - // #3535: one Escape closed the main menu and dropped the selection with it. - use egui_kittest::kittest::Queryable; + // #3535: one Escape closed the menu and dropped the selection with it. let (mut h, at) = harness(|_| {}); click(&mut h, at); click(&mut h, at); h.run_steps(2); assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick")); - h.get_by_label("Menu").click(); - h.run_steps(2); + crate::support::open_menu(&mut h, "View"); assert!(egui::Popup::is_any_open(&h.ctx), "the menu is open"); h.key_press(egui::Key::Escape); h.run_steps(2); diff --git a/crates/ui-egui/tests/it/theme.rs b/crates/ui-egui/tests/it/theme.rs index e5e930a971..a02c5c05ca 100644 --- a/crates/ui-egui/tests/it/theme.rs +++ b/crates/ui-egui/tests/it/theme.rs @@ -36,12 +36,9 @@ fn click_toolbar(h: &mut Harness<'static, PdfCraftApp>, label: &str) { } fn open_view_menu(h: &mut Harness<'static, PdfCraftApp>) { - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("View").hover(); - h.run_steps(3); + crate::support::open_menu(h, "View"); assert!(h.query_by_label("Switch light / dark theme").is_none()); - h.get_by_label("Display theme").hover(); + crate::support::menu_row(h, "Display theme").hover(); h.run_steps(3); for choice in ["Use system setting", "Light gray", "Dark gray"] { h.get_by_label(choice); diff --git a/crates/ui-egui/tests/it/trust_signer.rs b/crates/ui-egui/tests/it/trust_signer.rs index 27bd2f3c9e..c7a2ad739f 100644 --- a/crates/ui-egui/tests/it/trust_signer.rs +++ b/crates/ui-egui/tests/it/trust_signer.rs @@ -3,6 +3,7 @@ //! certificate whose CMS carries its issuer chain gives a signature whose `chain.last()` (the //! self-signed root) differs from its `certificate` (the signer); the click must trust the signer. +use crate::support; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_engine::SignatureStatus; @@ -58,7 +59,7 @@ fn one_click_trust_stores_the_displayed_signer_not_the_embedded_root() { d.password = "test".into(); } h.run_steps(2); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); // The signature validates as Unknown and carries a two-certificate chain whose top (the root) diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 12723274d5..cd5ce8dbb1 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -279,7 +279,7 @@ fn expanding_all_tools_from_read_mode_restores_the_right_panel() { fn expanding_all_tools_from_edit_puts_edit_text_down() { use pdfcraft_ui_egui::{LeftPanel, Mode, QuickTool}; let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); assert_eq!(h.state().quick_tool, QuickTool::EditText); h.state_mut().set_option("tools", "full").unwrap(); @@ -1640,7 +1640,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() { use pdfcraft_ui_egui::{Mode, QuickTool}; let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); assert_eq!(h.state().quick_tool, QuickTool::Select); - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); assert_eq!(h.state().mode, Mode::Edit); assert_eq!(h.state().quick_tool, QuickTool::EditText); @@ -1649,7 +1649,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() { assert_eq!(h.state().mode, Mode::AllTools); assert_eq!(h.state().quick_tool, QuickTool::Select); // Another tool picked inside Edit is not taken away by leaving it. - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); h.state_mut().quick_tool = QuickTool::Hand; h.get_by_label("Convert").click(); @@ -1663,7 +1663,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() { fn organize_pages_from_the_edit_panel_opens_its_panel() { use pdfcraft_ui_egui::LeftPanel; let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); assert_eq!(h.state().left, LeftPanel::Tool("edit")); assert!( @@ -1681,7 +1681,7 @@ fn organize_pages_from_the_edit_panel_opens_its_panel() { #[test] fn the_edit_panel_keeps_the_add_update_remove_menu() { let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); h.get_by_label("Watermark").click(); h.run_steps(3); diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 28eb6fd768..a83b4cb756 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1033,9 +1033,8 @@ fn view_menu_page_display_uses_the_same_commands() { use egui_kittest::kittest::Queryable; let mut h = harness(&[]); h.run_steps(4); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("View").hover(); + crate::support::open_menu(&mut h, "View"); + crate::support::menu_row(&h, "Page display").hover(); h.run_steps(3); h.get_by_label("Single page").click(); h.run_steps(4); @@ -2011,17 +2010,14 @@ fn long_view_menu_fits_a_short_window() { app }); h.run_steps(8); - h.get_by_label("Menu").click(); + crate::support::open_menu(&mut h, "View"); h.run_steps(2); - h.get_by_label("View").hover(); - h.run_steps(4); - let before = h.get_by_label("Side panels").rect().bottom(); - h.get_by_label("Fit to height ").hover(); + crate::support::menu_row(&h, "Rotate view").hover(); h.event(wheel(MouseWheelUnit::Point, -1000.0, TouchPhase::Move)); h.run_steps(6); - let after = h.get_by_label("Side panels").rect().bottom(); - assert!(after < before && after <= 400.0, "menu did not scroll into view: {before} -> {after}"); - h.get_by_label("Side panels").hover(); + let after = crate::support::menu_row(&h, "Side panels").rect().bottom(); + assert!(after <= 400.0, "the menu scrolls Side panels into view: {after}"); + crate::support::menu_row(&h, "Side panels").hover(); h.run_steps(4); h.get_all_by_label("Bookmarks").last().unwrap().click(); h.run_steps(4);