From f70c4caac902086fdc4cfbec7fc94c109f4a89d2 Mon Sep 17 00:00:00 2001 From: jcl-bit <336218001+jcl-bit@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:19:16 -0700 Subject: [PATCH 001/149] Security: create save staging files with create_new (follow-up to #164) (#319) * Security: create the --root staging file with create_new Follow-up to #164. write_atomic staged every save at the fixed name ..pdfcraft-tmp and wrote it with fs::write, which follows a symbolic link and writes through a hard link. So a link planted there, in a folder someone else can write to, redirected the bytes to any file the link pointed at. The staging file now gets a random 64-bit suffix (std's RandomState, keyed from the operating system's random source) and is opened with OpenOptions::create_new, which fails if anything already has the name (a file, a hard link, a symbolic link even when dangling), on Windows (CREATE_NEW) as on Unix (O_EXCL). A taken name is skipped, never opened; Windows reports a folder at the name as "access denied", which counts as taken when something is there. After 16 taken names the save fails and nothing is overwritten. A failed write or rename removes only the file this save created. At most 128 bytes of the target's name go into the staging name, so it fits every system's name limit. The #164 tests that planted a sentinel at the old fixed name now also compare folder listings, and say that the "is a folder" refusal is what they check. Co-Authored-By: Claude Opus 5.5 * Security: create the desktop app's save staging file with create_new The same fix as the previous commit, for the desktop app. editing::write_atomically, which Save, Save As, Export, Print to PDF, signing and crash recovery all use, staged at ..pdfcraft-.tmp and opened it with File::create, which follows a symbolic link and writes through a hard link. A process id is easy to guess, so a link planted beside a document in a shared folder could redirect a save into another file. The staging file now gets a random 64-bit suffix and is opened with OpenOptions::create_new; a taken name (on Windows, a folder reports "access denied") is skipped, never opened, and after 16 the save fails without overwriting anything. At most 128 bytes of the document's name go into the staging name. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- apps/pdfcraft-cli/tests/run_root.rs | 10 +- crates/automation/src/lib.rs | 212 +++++++++++++++++++++++- crates/automation/tests/automation.rs | 13 +- crates/ui-egui/src/editing.rs | 221 +++++++++++++++++++++++++- parity/acrobat-features.toml | 4 +- 5 files changed, 449 insertions(+), 11 deletions(-) diff --git a/apps/pdfcraft-cli/tests/run_root.rs b/apps/pdfcraft-cli/tests/run_root.rs index 2f9fb69b..511621cc 100644 --- a/apps/pdfcraft-cli/tests/run_root.rs +++ b/apps/pdfcraft-cli/tests/run_root.rs @@ -61,11 +61,19 @@ fn script_image_output_is_confined_to_the_root() { assert!(!base.join("outside/escaped.png").exists() && !base.join("outside/new").exists()); assert_eq!(std::fs::read_to_string(base.join("outside/existing.txt")).unwrap(), "SENTINEL\n"); - // "." is the root itself: refused, without staging a temporary file beside the root. + // "." is the root itself: refused, without staging a temporary file beside the root. The + // file at the old fixed staging name and the listing are canaries; staging names are random. std::fs::write(base.join(".root.pdfcraft-tmp"), "SENTINEL\n").unwrap(); + let listing = || { + let mut names: Vec<_> = std::fs::read_dir(&base).unwrap().map(|e| e.unwrap().file_name()).collect(); + names.sort(); + names + }; + let before = listing(); let o = render_to(&base, &base, Some(&root), "."); assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("is a folder"), "{}", String::from_utf8_lossy(&o.stderr)); assert_eq!(std::fs::read_to_string(base.join(".root.pdfcraft-tmp")).unwrap(), "SENTINEL\n"); + assert_eq!(listing(), before, "nothing was left beside the root"); // A relative path resolves inside the root, not in the working directory. let o = render_to(&base, &base, Some(&root), "relative.png"); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 1e199d2c..b76c71b1 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1720,6 +1720,12 @@ fn child(dir: &Path, name: &str) -> PathBuf { /// Write via a temporary file in the same directory, then rename over the target. fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { + write_atomic_with(path, bytes, staging_suffixes()) +} + +/// [`write_atomic`], trying the staging names that `suffixes` give. +fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator) -> Result<()> { + use std::io::Write; // A folder can't be replaced, and staging beside it could land outside the root: "." names // the root itself, whose parent isn't ours to write in. if path.is_dir() { @@ -1728,14 +1734,62 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); let name = path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?; std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let tmp = dir.join(format!(".{}.pdfcraft-tmp", name.to_string_lossy())); - std::fs::write(&tmp, bytes).map_err(|e| failed(format!("{}: {e}", tmp.display())))?; + let (tmp, file) = create_staging(dir, &name.to_string_lossy(), suffixes).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + // Closed at the end of the block, before the rename. + let written = { + let mut file = file; + file.write_all(bytes) + }; + if let Err(e) = written { + let _ = std::fs::remove_file(&tmp); + return Err(failed(format!("{}: {e}", tmp.display()))); + } std::fs::rename(&tmp, path).map_err(|e| { let _ = std::fs::remove_file(&tmp); failed(format!("{}: {e}", path.display())) }) } +/// How many staging names [`create_staging`] tries. A random 64-bit name is only taken if someone +/// put a file there on purpose, so running out means refusing, not trying harder. +const STAGING_ATTEMPTS: usize = 16; + +/// Create a new, empty staging file in `dir` for the file `name`, one name per suffix. It is +/// opened with `create_new`, which fails if anything already has the name (a file, a hard link, +/// a symbolic link even when dangling, a folder), on Windows as everywhere else; such a name is +/// skipped, never opened, so a file planted at the staging path can't receive or redirect the +/// write. +fn create_staging(dir: &Path, name: &str, suffixes: impl IntoIterator) -> std::io::Result<(PathBuf, std::fs::File)> { + // At most 128 bytes of the target's name, cut between characters, so the staging name fits + // the 255-byte (Linux, macOS) and 255-unit (Windows) limits however long that name is. + let mut stem = String::new(); + for c in name.chars() { + if stem.len() + c.len_utf8() > 128 { + break; + } + stem.push(c); + } + for suffix in suffixes.into_iter().take(STAGING_ATTEMPTS) { + let tmp = dir.join(format!(".{stem}.{suffix:016x}.pdfcraft-tmp")); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { + Ok(file) => return Ok((tmp, file)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + // Windows reports a folder at the name as "access denied"; it is taken all the same. + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied && tmp.symlink_metadata().is_ok() => {} + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every temporary file name tried is taken")) +} + +/// Unpredictable staging-name suffixes, so a name can't be planted in advance. `RandomState` is +/// keyed from the operating system's random source. +fn staging_suffixes() -> impl Iterator { + use std::hash::BuildHasher; + let state = std::hash::RandomState::new(); + (0u64..).map(move |i| state.hash_one(i)) +} + #[cfg(test)] mod tests { use super::*; @@ -1790,4 +1844,158 @@ mod tests { assert!(foreign_share(Path::new(r"\\server\other\x.pdf"), shared)); assert!(foreign_share(Path::new(r"\\attacker\docs\x.pdf"), shared)); } + + /// A fresh, empty folder for one staging test. + fn staging_dir(test: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-staging-{}-{test}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn staged(dir: &Path, suffix: u64) -> PathBuf { + dir.join(format!(".out.pdf.{suffix:016x}.pdfcraft-tmp")) + } + + fn read(p: &Path) -> String { + std::fs::read_to_string(p).unwrap() + } + + /// A symbolic link to a file, where the system allows one (Windows needs Developer Mode or an + /// administrator for it). + fn file_symlink(target: &Path, link: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + std::os::unix::fs::symlink(target, link) + } + #[cfg(windows)] + { + std::os::windows::fs::symlink_file(target, link) + } + #[cfg(not(any(unix, windows)))] + { + Err(std::io::Error::other(format!("no symbolic links here: {} {}", target.display(), link.display()))) + } + } + + #[test] + fn staging_never_writes_through_a_file_planted_at_its_name() { + let dir = staging_dir("planted"); + // A file elsewhere that a planted link points at. + let outside = dir.join("outside.txt"); + std::fs::write(&outside, "PRECIOUS").unwrap(); + let target = dir.join("out.pdf"); + // A hard link needs no privileges on any system, and writing to it writes to `outside`. + std::fs::hard_link(&outside, staged(&dir, 1)).unwrap(); + std::fs::write(staged(&dir, 2), "PLANTED").unwrap(); + write_atomic_with(&target, b"NEW", [1, 2, 3]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert_eq!(read(&outside), "PRECIOUS"); + assert_eq!(read(&staged(&dir, 1)), "PRECIOUS"); + assert_eq!(read(&staged(&dir, 2)), "PLANTED"); + assert!(!staged(&dir, 3).exists(), "the staging file was renamed into place"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn staging_never_writes_through_a_symbolic_link_planted_at_its_name() { + let dir = staging_dir("symlink"); + let outside = dir.join("outside.txt"); + std::fs::write(&outside, "PRECIOUS").unwrap(); + let target = dir.join("out.pdf"); + if let Err(e) = file_symlink(&outside, &staged(&dir, 1)) { + eprintln!("symbolic links not checked: {e}"); + return; + } + write_atomic_with(&target, b"NEW", [1, 2]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert_eq!(read(&outside), "PRECIOUS"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn staging_never_creates_a_file_through_a_dangling_link_at_its_name() { + let dir = staging_dir("dangling"); + let unborn = dir.join("created-through-a-link.txt"); + let target = dir.join("out.pdf"); + if let Err(e) = file_symlink(&unborn, &staged(&dir, 1)) { + eprintln!("symbolic links not checked: {e}"); + return; + } + write_atomic_with(&target, b"NEW", [1, 2]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert!(!unborn.exists(), "nothing was created through the dangling link"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn staging_gives_up_rather_than_reuse_a_taken_name() { + let dir = staging_dir("taken"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + for s in 1..=STAGING_ATTEMPTS as u64 { + std::fs::write(staged(&dir, s), "PLANTED").unwrap(); + } + assert!(write_atomic_with(&target, b"NEW", 1..).is_err()); + assert_eq!(read(&target), "OLD"); + for s in 1..=STAGING_ATTEMPTS as u64 { + assert_eq!(read(&staged(&dir, s)), "PLANTED"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// The names in a folder: what a test can see was left behind. + fn listing(dir: &Path) -> Vec { + let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + names + } + + #[test] + fn staging_names_differ_between_saves_and_fit_long_names() { + let suffixes: std::collections::HashSet = staging_suffixes().take(64).collect(); + assert_eq!(suffixes.len(), 64); + assert_ne!(staging_suffixes().next(), staging_suffixes().next(), "each save draws new names"); + // 60 four-byte characters: a 244-byte name, within every system's limit. Its staging name + // must be too (on Linux the whole name in it would be 275 bytes). + let dir = staging_dir("long"); + let name = format!("{}.pdf", "\u{1F600}".repeat(60)); + write_atomic(&dir.join(&name), b"NEW").unwrap(); + assert_eq!(read(&dir.join(&name)), "NEW"); + assert_eq!(listing(&dir), [name], "no staging file is left behind"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_folder_at_the_staging_name_is_left_alone() { + let dir = staging_dir("folder"); + std::fs::create_dir(staged(&dir, 1)).unwrap(); + std::fs::write(staged(&dir, 1).join("inside.txt"), "PLANTED").unwrap(); + let target = dir.join("out.pdf"); + write_atomic_with(&target, b"NEW", [1, 2]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert_eq!(read(&staged(&dir, 1).join("inside.txt")), "PLANTED"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Windows refuses to replace a read-only file, so the rename fails: the staging file must not + /// be left behind. + #[cfg(windows)] + #[test] + fn a_failed_rename_removes_the_staging_file() { + let dir = staging_dir("readonly"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let mut perms = std::fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&target, perms.clone()).unwrap(); + let e = write_atomic_with(&target, b"NEW", [7]).unwrap_err(); + assert!(e.to_string().contains(&target.display().to_string()), "the rename failed, not the staging: {e}"); + assert_eq!(read(&target), "OLD"); + assert_eq!(listing(&dir), ["out.pdf"], "the staging file was removed"); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&target, perms).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 3083dce4..0f85ec2a 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -405,12 +405,20 @@ fn root_refusals_do_not_reveal_what_exists_outside() { #[test] fn writing_to_a_folder_touches_nothing_beside_it() { // "." names the root itself. Saving there used to stage its temporary file next to the - // root, outside it, overwriting and then deleting any file of that name. + // root, outside it, overwriting and then deleting any file of that name. Staging names are + // random now and a failed rename removes the staging file, so the "is a folder" refusal is + // what this checks; the listings and the file at the old staging name are canaries. let (base, root) = sandbox("root-itself"); let mut a = auto(&root); let beside = base.join(".root.pdfcraft-tmp"); std::fs::write(&beside, "SENTINEL").unwrap(); std::fs::create_dir_all(root.join("folder")).unwrap(); + let listing = |dir: &Path| { + let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + names.sort(); + names + }; + let (base_before, folder_before) = (listing(&base), listing(&root.join("folder"))); let doc = ok(&mut a, "doc_open", json!({ "path": "inside.pdf" }))["doc"].as_u64().unwrap(); for p in [".", "", "folder", "folder/"] { let e = a.call("doc_save", &json!({ "doc": doc, "path": p })).unwrap_err(); @@ -419,7 +427,8 @@ fn writing_to_a_folder_touches_nothing_beside_it() { let png = vec![1, 2, 3]; assert!(a.write_output(".", &png).is_err()); assert_eq!(std::fs::read_to_string(&beside).unwrap(), "SENTINEL"); - assert!(!root.join(".folder.pdfcraft-tmp").exists()); + assert_eq!(listing(&base), base_before, "nothing was left beside the root"); + assert_eq!(listing(&root.join("folder")), folder_before, "nothing was left in the folder"); // `image_save` adds an extension when the path has none, which turned "." into `root.png` // beside the root. ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18 })); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 51d97cb4..02378e64 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -492,14 +492,23 @@ fn comment_page(edit: &Edit) -> Option { /// Write via a temporary file in the same directory and rename over the target, so a crash or /// full disk never leaves a half-written PDF where the original was. pub fn write_atomically(path: &str, bytes: &[u8]) -> std::io::Result<()> { + write_atomically_with(path, bytes, staging_suffixes()) +} + +/// [`write_atomically`], trying the staging names that `suffixes` give. +fn write_atomically_with(path: &str, bytes: &[u8], suffixes: impl IntoIterator) -> std::io::Result<()> { use std::io::Write; let target = std::path::Path::new(path); let dir = target.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new(".")); - let tmp = dir.join(format!(".{}.pdfcraft-{}.tmp", target.file_name().and_then(|n| n.to_str()).unwrap_or("save"), std::process::id())); + let name = target.file_name().and_then(|n| n.to_str()).unwrap_or("save"); + let (tmp, f) = create_staging(dir, name, suffixes)?; let result = (|| { - let mut f = std::fs::File::create(&tmp)?; - f.write_all(bytes)?; - f.sync_all()?; + // Closed at the end of the block, before the rename. + { + let mut f = f; + f.write_all(bytes)?; + f.sync_all()?; + } std::fs::rename(&tmp, target) })(); if result.is_err() { @@ -508,6 +517,50 @@ pub fn write_atomically(path: &str, bytes: &[u8]) -> std::io::Result<()> { result } +/// How many staging names [`create_staging`] tries. A random 64-bit name is only taken if someone +/// put a file there on purpose, so running out means refusing, not trying harder. +const STAGING_ATTEMPTS: usize = 16; + +/// Create a new, empty staging file in `dir` for the file `name`, one name per suffix. It is +/// opened with `create_new`, which fails if anything already has the name (a file, a hard link, +/// a symbolic link even when dangling, a folder), on Windows as everywhere else; such a name is +/// skipped, never opened, so a file planted at the staging path can't receive or redirect the +/// save. +fn create_staging( + dir: &std::path::Path, + name: &str, + suffixes: impl IntoIterator, +) -> std::io::Result<(std::path::PathBuf, std::fs::File)> { + // At most 128 bytes of the target's name, cut between characters, so the staging name fits + // the 255-byte (Linux, macOS) and 255-unit (Windows) limits however long that name is. + let mut stem = String::new(); + for c in name.chars() { + if stem.len() + c.len_utf8() > 128 { + break; + } + stem.push(c); + } + for suffix in suffixes.into_iter().take(STAGING_ATTEMPTS) { + let tmp = dir.join(format!(".{stem}.pdfcraft-{suffix:016x}.tmp")); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { + Ok(file) => return Ok((tmp, file)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + // Windows reports a folder at the name as "access denied"; it is taken all the same. + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied && tmp.symlink_metadata().is_ok() => {} + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every temporary file name tried is taken")) +} + +/// Unpredictable staging-name suffixes, so a name can't be planted in advance. `RandomState` is +/// keyed from the operating system's random source. +fn staging_suffixes() -> impl Iterator { + use std::hash::BuildHasher; + let state = std::hash::RandomState::new(); + (0u64..).map(move |i| state.hash_one(i)) +} + #[cfg(not(target_arch = "wasm32"))] fn short_name(path: &str) -> String { std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()) @@ -586,3 +639,163 @@ fn bookmark_at<'a>(items: &'a [pdfcraft_render::OutlineItem], path: &[usize]) -> let item = items.get(*first)?; if rest.is_empty() { Some(item) } else { bookmark_at(&item.children, rest) } } + +#[cfg(test)] +mod tests { + use super::{STAGING_ATTEMPTS, staging_suffixes, write_atomically, write_atomically_with}; + use std::path::{Path, PathBuf}; + + /// A fresh, empty folder for one staging test. + fn staging_dir(test: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-staging-{}-{test}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn staged(dir: &Path, suffix: u64) -> PathBuf { + dir.join(format!(".out.pdf.pdfcraft-{suffix:016x}.tmp")) + } + + fn read(p: &Path) -> String { + std::fs::read_to_string(p).unwrap() + } + + /// A symbolic link to a file, where the system allows one (Windows needs Developer Mode or an + /// administrator for it). + fn file_symlink(target: &Path, link: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + std::os::unix::fs::symlink(target, link) + } + #[cfg(windows)] + { + std::os::windows::fs::symlink_file(target, link) + } + #[cfg(not(any(unix, windows)))] + { + Err(std::io::Error::other(format!("no symbolic links here: {} {}", target.display(), link.display()))) + } + } + + #[test] + fn saving_never_writes_through_a_file_planted_at_the_staging_name() { + let dir = staging_dir("planted"); + let outside = dir.join("outside.txt"); + std::fs::write(&outside, "PRECIOUS").unwrap(); + let target = dir.join("out.pdf"); + let path = target.to_string_lossy().into_owned(); + // A hard link needs no privileges on any system, and writing to it writes to `outside`. + std::fs::hard_link(&outside, staged(&dir, 1)).unwrap(); + std::fs::write(staged(&dir, 2), "PLANTED").unwrap(); + write_atomically_with(&path, b"NEW", [1, 2, 3]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert_eq!(read(&outside), "PRECIOUS"); + assert_eq!(read(&staged(&dir, 1)), "PRECIOUS"); + assert_eq!(read(&staged(&dir, 2)), "PLANTED"); + assert!(!staged(&dir, 3).exists(), "the staging file was renamed into place"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn saving_never_writes_through_a_symbolic_link_at_the_staging_name() { + let dir = staging_dir("symlink"); + let outside = dir.join("outside.txt"); + std::fs::write(&outside, "PRECIOUS").unwrap(); + let target = dir.join("out.pdf"); + if let Err(e) = file_symlink(&outside, &staged(&dir, 1)) { + eprintln!("symbolic links not checked: {e}"); + return; + } + write_atomically_with(&target.to_string_lossy(), b"NEW", [1, 2]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert_eq!(read(&outside), "PRECIOUS"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn saving_never_creates_a_file_through_a_dangling_link_at_the_staging_name() { + let dir = staging_dir("dangling"); + let unborn = dir.join("created-through-a-link.txt"); + let target = dir.join("out.pdf"); + if let Err(e) = file_symlink(&unborn, &staged(&dir, 1)) { + eprintln!("symbolic links not checked: {e}"); + return; + } + write_atomically_with(&target.to_string_lossy(), b"NEW", [1, 2]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert!(!unborn.exists(), "nothing was created through the dangling link"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_folder_at_the_staging_name_is_left_alone() { + let dir = staging_dir("folder"); + std::fs::create_dir(staged(&dir, 1)).unwrap(); + std::fs::write(staged(&dir, 1).join("inside.txt"), "PLANTED").unwrap(); + let target = dir.join("out.pdf"); + write_atomically_with(&target.to_string_lossy(), b"NEW", [1, 2]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert_eq!(read(&staged(&dir, 1).join("inside.txt")), "PLANTED"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn saving_gives_up_rather_than_reuse_a_taken_name() { + let dir = staging_dir("taken"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + for s in 1..=STAGING_ATTEMPTS as u64 { + std::fs::write(staged(&dir, s), "PLANTED").unwrap(); + } + assert!(write_atomically_with(&target.to_string_lossy(), b"NEW", 1..).is_err()); + assert_eq!(read(&target), "OLD"); + for s in 1..=STAGING_ATTEMPTS as u64 { + assert_eq!(read(&staged(&dir, s)), "PLANTED"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// The names in a folder: what a test can see was left behind. + fn listing(dir: &Path) -> Vec { + let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + names + } + + #[test] + fn saving_a_long_name_leaves_only_the_saved_file() { + assert_ne!(staging_suffixes().next(), staging_suffixes().next(), "each save draws new names"); + // 60 four-byte characters: a 244-byte name, within every system's limit. Its staging name + // must be too (on Linux the whole name in it would be 275 bytes). + let dir = staging_dir("clean"); + let name = format!("{}.pdf", "\u{1F600}".repeat(60)); + let path = dir.join(&name).to_string_lossy().into_owned(); + write_atomically(&path, b"NEW").unwrap(); + write_atomically(&path, b"NEWER").unwrap(); + assert_eq!(read(&dir.join(&name)), "NEWER"); + assert_eq!(listing(&dir), [name], "no staging file is left behind"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Windows refuses to replace a read-only file, so the rename fails: the staging file must not + /// be left behind. + #[cfg(windows)] + #[test] + fn a_failed_save_removes_the_staging_file() { + let dir = staging_dir("readonly"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let mut perms = std::fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&target, perms.clone()).unwrap(); + let e = write_atomically_with(&target.to_string_lossy(), b"NEW", [7]).unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "the rename failed: {e}"); + assert_eq!(read(&target), "OLD"); + assert_eq!(listing(&dir), ["out.pdf"], "the staging file was removed"); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&target, perms).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 16674649..9cfa9f7b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -274,8 +274,8 @@ milestone = "M1" status = "shipped" commands = ["file.save"] tools = ["doc_save"] -evidence = ["crates/ui-egui/tests/editing.rs::save_writes_an_incremental_update_and_clears_dirty", "crates/engine/src/tests.rs::save_is_incremental_stamps_mod_date_and_rebases", "crates/organize/src/tests.rs::successive_incremental_saves_stack_revisions"] -notes = "" +evidence = ["crates/ui-egui/tests/editing.rs::save_writes_an_incremental_update_and_clears_dirty", "crates/engine/src/tests.rs::save_is_incremental_stamps_mod_date_and_rebases", "crates/organize/src/tests.rs::successive_incremental_saves_stack_revisions", "crates/automation/src/lib.rs::staging_never_writes_through_a_file_planted_at_its_name", "crates/ui-egui/src/editing.rs::saving_never_writes_through_a_file_planted_at_the_staging_name"] +notes = "The atomic write stages to a new, randomly named file created with create_new, so a file or link planted at the staging path is never written through (desktop app, automation and CLI)." [[feature]] id = "core.save-as-full-rewrite" From 9140b44792b59e93029a216117a6d5aa9ffd55c7 Mon Sep 17 00:00:00 2001 From: mlkonrad Date: Thu, 8 Oct 2026 22:37:10 +0300 Subject: [PATCH 002/149] M4: Combine files in its own tab, with a sortable table and unlocking of protected files (#321) * M4: Combine files in its own tab, with a sortable table and unlocking of protected files Combine files was a modal dialog with a cramped list and up/down buttons on every row. It is now a tab next to Home and the documents, showing a table of the files: File name | Pages | Size | Modified | Warnings. - Columns sort when their heading is clicked (anywhere in the cell; again to reverse; names in natural order), resize from their edges and move by dragging the heading. Order and widths are kept in the settings; a right-click on a heading resets them. - Rows select like a file manager (click, Ctrl/Cmd+click, Shift+click, Shift+arrows, Ctrl/Cmd+A). One toolbar moves, removes and unlocks the selection; Alt+arrows and dragging move it too. Every change to the list undoes and redoes with Cmd/Ctrl+Z (also toolbar buttons). - Add files, Add folder, Add folder and subfolders (a split button), Add open documents, or drop files and folders on the tab. - Problems show before Combine: password-protected files, security settings that withhold page assembly, page ranges (checked as typed, with the pages they take), duplicates. Files that open but are protected say that the combined file won't keep their security; the result is never encrypted. - Unlock... asks for a file's open or permissions password (one password can be tried on several files). Passwords stay in memory, are never printed, and reach the engine through combine_unlocked; doc_combine takes them as `passwords`. The engine decides whether a password works, since the inspector rejects some it accepts (RC4 owner passwords). organize.combine-sort is shipped and organize.combine-options partial (include open files). Strings are translated for es, fr, ja, zh-hans, zh-hant. Co-Authored-By: Claude Opus 5.5 * Credit Marlon Konrad (mlkonrad) in the About window Adds my own entry to contributors/people.toml, as docs/contributors.md asks. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 10 +- ROADMAP.md | 4 +- contributors/people.toml | 5 + crates/automation/src/lib.rs | 10 +- crates/automation/src/tools.rs | 3 +- crates/automation/tests/automation.rs | 27 + crates/engine/src/lib.rs | 61 +- crates/engine/src/tests.rs | 32 + crates/ui-egui/src/chrome.rs | 26 +- crates/ui-egui/src/combine_ui.rs | 1556 +++++++++++++++++++++++-- crates/ui-egui/src/commands.rs | 6 +- crates/ui-egui/src/dialogs.rs | 14 - crates/ui-egui/src/editing.rs | 13 +- crates/ui-egui/src/files.rs | 53 +- crates/ui-egui/src/i18n/es.tsv | 58 +- crates/ui-egui/src/i18n/fr.tsv | 56 + crates/ui-egui/src/i18n/ja.tsv | 58 +- crates/ui-egui/src/i18n/zh-hans.tsv | 58 +- crates/ui-egui/src/i18n/zh-hant.tsv | 58 +- crates/ui-egui/src/lib.rs | 27 +- crates/ui-egui/src/widgets.rs | 28 + crates/ui-egui/tests/editing.rs | 424 ++++++- crates/ui-egui/tests/ui.rs | 40 + parity/acrobat-features.toml | 24 +- 24 files changed, 2500 insertions(+), 151 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 36087581..0d355408 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5da2aa2944fcad4720972062f0f97800f73b8bfa5dd0fff6c28f4ec0f8ee19f9" +sha256 = "cac5e47a10ff49a6d41d87869fda1760778b533319f8ccec2b4e8e67b8a8e0dd" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "39f83ab539f6380df995a855c1c8e8c036bcd060e06b9d1b027771e1e7d6e7ef" +sha256 = "97de2a56aa5cf32d11f22c19f0efeb76cbe16dff769247f49d8bdecbeac34d90" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4c409434bd3385b56b561c36ff819e19e574bda8979d16cd53232cb5bea3c65f" +sha256 = "6d2da5a55e6eb10e26efbdd4d34e48be30c2cad8e3f43e1fe811d8ce95dd0a19" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d322ac956218961d22828726a7fe0752b0a68603679c2dcf17c5aa840995e124" +sha256 = "2be55ac9d1303fea2d61f3f6c594cf5003e2b2aa405eefddffed2ea158a94b5e" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4b14f85bc0890823c30bff37723ecb05bc5ea5a8583334644168b08043ab6bde" +sha256 = "f2a9449db49de5226000d0b14b991ab2318a82e4ca63624b11ea4a753ae2ea74" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/ROADMAP.md b/ROADMAP.md index ecd96d48..a3c9f48b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -93,7 +93,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t | M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | | M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro | | M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel. | -| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web | +| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 45% | 95–185 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 17% | 210–420 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | @@ -133,6 +133,8 @@ Newest first. One line per session: the date, what moved, and the new overall pe - **2026-10-09 (M14, #305):** Windows MSI rejects per-user installation overrides while allowing removal of legacy per-user installs and keeps its progress message visible. README documents unattended deployment without a desktop shortcut. Compiled x64 MSI checks pass; ARM64 install regressions added for CI. Overall estimate unchanged (about 30–35%). +- **2026-10-08 (M4, Combine files):** Combine files is now its own tab instead of a dialog: a table of the files (name, pages, size, modified, warnings) whose columns sort, resize and move, with the layout kept in the settings. Files, folders (optionally with subfolders) and open documents can be added, or dropped on the tab; rows select like a file manager (Ctrl/Shift), move as a block by toolbar, Alt+arrows or drag, and every change undoes with ⌘Z. Problems show before Combine: password-protected files, security settings that withhold page assembly, bad page ranges (checked as typed), duplicates; protected files unlock with their open or permissions password, also headlessly (`doc_combine` `passwords`). The result is never encrypted, and the list says so. `organize.combine-sort` shipped; `organize.combine-options` partial (open files). Still missing: protecting the result in the same step, file-size and PDF/A options. Overall estimate unchanged (about 30–35%). + - **2026-10-08 (M14, French):** Français (`fr`) is a complete interface catalog (about 1,880 entries), selected automatically for `fr`, `fr_FR`, `fr_CA` and other French locales, and from Preferences. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names. Latin UI faces cover the catalog. Localization remains partial for Czech and Brazilian Portuguese; overall ≈ 30–35% (unchanged). - **2026-10-08 (M14, right-to-left file names):** A tab with an Arabic or Hebrew file name showed replacement boxes. The interface now takes an `Arab` face from craft-fonts when the build input has one, and on desktop falls back to one font installed on the machine for characters no embedded face has (`PDFCRAFT_SYSTEM_FONTS=0` turns that off; README screenshots do). File names and paths in the tab strip, the recent-files list and Properties are put in display order, since egui joins the letters but does not reorder runs. Still missing: multi-line text (bookmarks, comments, search results), text input and a mirrored layout, so `misc.rtl-ui` stays planned; the craft-fonts revision pinned for releases predates its Arabic face, so release builds rely on the installed font until the pin moves. Overall estimate unchanged (about 30–35%). diff --git a/contributors/people.toml b/contributors/people.toml index c0549e9d..d84dff38 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -36,3 +36,8 @@ sync_github_name = false real_name = "Hal Goff" display_name = "Hal Goff, HG Studio" sync_github_name = false + +[people.mlkonrad] +real_name = "Marlon Konrad" +display_name = "Marlon Konrad" +sync_github_name = false diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index b76c71b1..952f2ff9 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1178,6 +1178,13 @@ impl Automation { Some(Value::Array(v)) if v.len() == paths.len() => v.iter().map(|x| x.as_str().map(str::to_owned)).collect(), Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), }; + let passwords: Vec> = match a.get("passwords") { + None | Some(Value::Null) => vec![None; paths.len()], + Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_string() || x.is_null()) => { + v.iter().map(|x| x.as_str().map(str::to_owned)).collect() + } + Some(_) => return Err(ToolError::InvalidArgs("passwords must list a password (or null) for each path".into())), + }; let mut sources = Vec::new(); for (p, range) in paths.into_iter().zip(ranges) { let path = self.resolve(p, false)?; @@ -1185,7 +1192,8 @@ impl Automation { let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); sources.push((name, Arc::new(bytes), range)); } - let bytes = self.session.combine_ranges(&sources).map_err(failed)?; + let passwords: Vec> = passwords.iter().map(Option::as_deref).collect(); + let bytes = self.session.combine_unlocked(&sources, &passwords).map_err(failed)?; self.deliver(a, "Combined", bytes) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 2588efaa..a6838150 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -185,12 +185,13 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone(), "separate": { "type": "boolean" }, "out_dir": { "type": "string" }, "delete": { "type": "boolean" } }), &["doc", "pages"], )), - t("doc_combine", "Combine files", "Combine PDFs, in order, into one (bookmarks are kept under one entry per file). pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages.") + t("doc_combine", "Combine files", "Combine PDFs, in order, into one (bookmarks are kept under one entry per file). pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back.") .cmd("page.combine") .with(schema( json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 2 }, "pages": { "type": "array", "items": { "type": ["string", "null"] } }, + "passwords": { "type": "array", "items": { "type": ["string", "null"] } }, "out": save_out, "open": open, }), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 0f85ec2a..65e5702d 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -756,6 +756,33 @@ fn protecting_through_tools() { ok(&mut c, "doc_open", json!({ "path": "open.pdf" })); } +#[test] +fn combine_opens_protected_files_with_their_passwords() { + let dir = workdir("combine-passwords"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // Opens with "open"; only "boss" may assemble pages. + ok(&mut a, "doc_protect", json!({ "doc": doc, "open_password": "open", "permissions_password": "boss", "changes": "none" })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "locked.pdf" })); + let combine = |a: &mut Automation, passwords: Value| { + a.call("doc_combine", &json!({ "paths": ["locked.pdf", "b.pdf"], "passwords": passwords, "open": true })) + }; + let err = |r: Result<_, ToolError>| match r { + Err(ToolError::Failed(m)) => m, + Err(other) => panic!("expected a failure, got {other:?}"), + Ok(_) => panic!("expected a failure"), + }; + assert!(err(combine(&mut a, Value::Null)).contains("password-protected")); + assert!(err(combine(&mut a, json!(["wrong", null]))).contains("password is wrong")); + assert!(err(combine(&mut a, json!(["open", null]))).contains("don't allow copying pages")); + assert!(matches!(combine(&mut a, json!(["boss"])), Err(ToolError::InvalidArgs(_))), "one per path"); + let done = ok(&mut a, "doc_combine", json!({ "paths": ["locked.pdf", "b.pdf"], "passwords": ["boss", null], "open": true })); + assert!(!done.to_string().contains("boss"), "passwords are never echoed"); + let out = done["document"]["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, out), ["Page 1", "Page 2", "Page 3", "Page 1", "Page 2"]); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": out }))["security"]["protected"], false, "the result is not encrypted"); +} + /// Restrictions exist only behind a permissions password: open_password alone encrypts and /// restricts nothing, and asking for a restriction without one is refused rather than ignored (#134). #[test] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c7ce0650..2e623506 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1734,17 +1734,52 @@ pub fn guard(f: impl FnOnce() -> T) -> Result { /// Parse another PDF to copy pages from. fn open_source(name: &str, bytes: &Arc>) -> Result { - match std::panic::catch_unwind(|| pdfcraft_cos::Document::open(bytes.clone())) { - Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => { - Err(EditError::Source(format!("{name}: its security settings don't allow copying pages"))) - } + 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}"))) +} + +/// 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), Ok(Ok(d)) => Ok(d), - Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(EditError::Source(format!("{name}: it is password-protected"))), - Ok(Err(e)) => Err(EditError::Source(format!("{name}: {e}"))), - Err(_) => Err(EditError::Source(format!("{name}: the file could not be read"))), + 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() +} + fn plural(s: &str, n: usize) -> String { if n == 1 { s.to_string() } else { format!("{s}s") } } @@ -2574,7 +2609,17 @@ impl Session { /// 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> { - let docs = sources.iter().map(|(n, b, _)| open_source(n, b)).collect::, _>>()?; + self.combine_unlocked(sources, &[]) + } + + /// [`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> { + 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()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6689667c..07f6b9af 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -631,6 +631,38 @@ fn permissions_password_restricts_others_but_not_this_session() { assert!(s2.get(id3).unwrap().allows_security_change()); } +#[test] +fn combine_source_check_says_why_a_file_cannot_be_combined() { + let (mut s, id) = session_with(1); + let plain = s.get(id).unwrap().bytes.clone(); + assert_eq!(crate::combine_source_check(&plain, None), Ok(())); + // Page assembly withheld (Changes::None) without a password to open it. + let p = Protection { changes: Changes::None, ..protection(None, Some("boss")) }; + s.apply(id, Edit::Protect(p)).unwrap(); + let locked = s.save_bytes(id).unwrap(); + assert_eq!(crate::combine_source_check(&locked, None), Err(crate::SourceProblem::NotPermitted)); + let (mut s, id) = session_with(1); + s.apply(id, Edit::Protect(protection(Some("pw"), Some("owner")))).unwrap(); + let secret = s.save_bytes(id).unwrap(); + assert_eq!(crate::combine_source_check(&secret, None), Err(crate::SourceProblem::Password)); + assert_eq!(crate::combine_source_check(&secret, Some("nope")), Err(crate::SourceProblem::WrongPassword)); + // Its open password reads it, but its permissions withhold assembling pages: the owner's + // password allows it. + assert_eq!(crate::combine_source_check(&secret, Some("pw")), Err(crate::SourceProblem::NotPermitted)); + assert_eq!(crate::combine_source_check(&secret, Some("owner")), Ok(())); + // The permissions password lifts the restriction on copying pages. + assert_eq!(crate::combine_source_check(&locked, Some("boss")), Ok(())); + // Combining with the passwords: the result opens without any. + let s = Session::new(); + let sources = vec![("secret".to_string(), secret, None), ("locked".to_string(), locked, None)]; + assert!(s.combine_ranges(&sources).is_err(), "no passwords, no combining"); + let out = s.combine_unlocked(&sources, &[Some("owner"), Some("boss")]).unwrap(); + let combined = pdfcraft_cos::Document::open(out).unwrap(); + assert!(combined.permissions().is_none(), "the combined file is not encrypted"); + assert_eq!(pdfcraft_organize::page_count(&combined).unwrap(), 2); + assert!(matches!(crate::combine_source_check(&Arc::new(b"not a pdf".to_vec()), None), Err(crate::SourceProblem::Unreadable(_)))); +} + #[test] fn protection_is_validated_undoable_and_never_logged() { let (mut s, id) = session_with(1); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c4591444..41df36e7 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -24,20 +24,31 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 4.0; - if icons::button(ui, "house", 28.0, app.active.is_none(), tl!("Home")).clicked() { + if icons::button(ui, "house", 28.0, app.active.is_none() && !app.combine_showing(), tl!("Home")).clicked() { app.active = None; + app.combine_tab.focused = false; } let mut close = None; for i in 0..app.views.len() { let Some(doc) = app.session.get(app.views[i].id) else { continue }; let (name, dirty) = (doc.display_name(), doc.dirty); - if tab(ui, &t, &name, dirty, app.active == Some(i), &mut close, i).clicked() { + if tab(ui, &t, "file-text", &name, dirty, app.active == Some(i), &mut close, i).clicked() { app.active = Some(i); } } if let Some(i) = close { app.request_close_tab(i); } + if app.combine_tab.open { + // After the document tabs; its index can't clash with theirs. + let mut close = None; + if tab(ui, &t, "files", tl!("Combine files"), false, app.combine_showing(), &mut close, usize::MAX).clicked() { + app.open_combine_tab(); + } + if close.is_some() { + app.close_combine_tab(); + } + } ui.add_space(4.0); if widgets::ghost_button(ui, "plus", tl!("Open")).on_hover_text(tl!("Open a PDF (⌘O)")).clicked() { app.open_dialog(); @@ -77,7 +88,8 @@ fn theme_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } } -fn tab(ui: &mut egui::Ui, t: &Tokens, name: &str, dirty: bool, active: bool, close: &mut Option, index: usize) -> egui::Response { +#[allow(clippy::too_many_arguments)] +fn tab(ui: &mut egui::Ui, t: &Tokens, icon: &str, name: &str, dirty: bool, active: bool, close: &mut Option, index: usize) -> egui::Response { let font = theme::regular(13.0); let label: String = if name.chars().count() > 28 { format!("{}…", name.chars().take(27).collect::()) } else { name.to_string() }; // Painted text only: the accessibility name below keeps the logical order. @@ -94,13 +106,7 @@ fn tab(ui: &mut egui::Ui, t: &Tokens, name: &str, dirty: bool, active: bool, clo Color32::TRANSPARENT }; ui.painter().rect_filled(rect, CornerRadius { nw: 7, ne: 7, sw: 0, se: 0 }, bg); - icons::paint( - ui, - Rect::from_min_size(rect.min + vec2(6.0, 7.0), vec2(16.0, 16.0)), - "file-text", - 15.0, - if active { t.accent } else { t.text_muted }, - ); + icons::paint(ui, Rect::from_min_size(rect.min + vec2(6.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, if active { t.accent } else { t.text_muted }); ui.painter().text(rect.min + vec2(28.0, rect.height() / 2.0), Align2::LEFT_CENTER, label, font, if active { t.text } else { t.text_muted }); let x_rect = Rect::from_center_size(rect.right_center() - vec2(16.0, 0.0), vec2(20.0, 20.0)); let x = ui.interact(x_rect, ui.id().with(("tabclose", index)), Sense::click()); diff --git a/crates/ui-egui/src/combine_ui.rs b/crates/ui-egui/src/combine_ui.rs index 92ea6da5..780d3c5e 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -1,128 +1,1526 @@ -//! Combine files: the files to combine, in order, each with the pages to take (all, or a range -//! such as "1-3, 6"); reorder, remove or add files, then Combine. +//! Combine files, in its own tab: the files to combine, in order, each with the pages to take +//! (all, or a range such as "1-3, 6"), its size, when it was modified and anything that stops it +//! being combined. Rows are selected like files in a file manager (click, Ctrl/⌘+click, +//! Shift+click), moved with the toolbar, the keyboard or by dragging, and sorted by clicking a +//! column heading; PDFs dropped on the tab join the list. Every change to the list can be undone. +use std::cmp::Ordering; +use std::collections::BTreeSet; use std::sync::Arc; +use std::time::SystemTime; -use egui::{Align, Layout}; +use egui::{Align, Color32, CornerRadius, Layout, Modifiers, Rect, Sense, Stroke, vec2}; +use egui_extras::{Column, TableBuilder}; +use pdfcraft_engine::SourceProblem; use crate::theme::{self, Tokens}; -use crate::{Dialog, PdfCraftApp, icons, widgets}; +use crate::{PdfCraftApp, icons, widgets}; + +/// Same reds and ambers as the signature status (`sign_ui`). +const ERROR: Color32 = Color32::from_rgb(0xD7, 0x37, 0x3F); +const WARNING: Color32 = Color32::from_rgb(0xE6, 0x86, 0x19); +/// Undo steps kept for the list (each holds the list itself; the files' bytes are shared). +const HISTORY: usize = 100; + +/// Files found in a folder are added up to this many at a time. +#[cfg(not(target_arch = "wasm32"))] +const MAX_FOLDER_FILES: usize = 1000; +/// How deep "Add folder and subfolders" looks. +#[cfg(not(target_arch = "wasm32"))] +const MAX_FOLDER_DEPTH: usize = 16; + +/// A column of the list: what it shows and what it sorts by. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SortKey { + Name, + Pages, + Size, + Modified, + Warnings, +} + +impl SortKey { + pub const ALL: [SortKey; 5] = [SortKey::Name, SortKey::Pages, SortKey::Size, SortKey::Modified, SortKey::Warnings]; + + fn index(self) -> usize { + self as usize + } + + fn label(self) -> &'static str { + match self { + SortKey::Name => tl!("File name"), + SortKey::Pages => tl!("Pages"), + SortKey::Size => tl!("Size"), + SortKey::Modified => tl!("Modified"), + SortKey::Warnings => tl!("Warnings"), + } + } + + /// The narrowest the column may be dragged. + fn min_width(self) -> f32 { + match self { + SortKey::Name => 120.0, + SortKey::Pages => 110.0, + SortKey::Size => 60.0, + SortKey::Modified => 80.0, + SortKey::Warnings => 100.0, + } + } +} + +/// The columns' order and widths, as the user arranged them (kept in the settings). +#[derive(Clone, Debug, PartialEq)] +pub struct Columns { + pub order: [SortKey; 5], + /// By [`SortKey::index`]; `None` until a column is resized: fitted to the window until then. + pub widths: Option<[f32; 5]>, +} + +impl Default for Columns { + fn default() -> Self { + Columns { order: SortKey::ALL, widths: None } + } +} + +impl Columns { + pub(crate) fn to_json(&self) -> serde_json::Value { + serde_json::json!({ "order": self.order, "widths": self.widths }) + } + + /// Settings written by [`Self::to_json`]; anything malformed gives the default layout. + pub(crate) fn from_json(v: &serde_json::Value) -> Self { + let order = serde_json::from_value::>(v["order"].clone()) + .ok() + .filter(|o| SortKey::ALL.iter().all(|k| o.contains(k))) + .and_then(|o| <[SortKey; 5]>::try_from(o).ok()) + .unwrap_or(SortKey::ALL); + let widths = serde_json::from_value::<[f32; 5]>(v["widths"].clone()) + .ok() + .filter(|w| w.iter().all(|x| x.is_finite())) + .map(|w| std::array::from_fn(|i| w[i].clamp(SortKey::ALL[i].min_width(), 4000.0))); + Columns { order, widths } + } + + /// The widths to show: the user's, or the window's width shared out. + fn resolved(&self, available: f32) -> [f32; 5] { + if let Some(w) = self.widths { + return w; + } + let fixed = 170.0 + 84.0 + 110.0; + let flexible = (available - fixed).max(260.0); + [(flexible * 0.55).max(140.0), 170.0, 84.0, 110.0, (flexible * 0.45).max(120.0)] + } +} + +/// A column heading being dragged to a new place. +#[derive(Clone, Copy)] +struct HeadingDrag(SortKey); + +enum HeadingEvent { + Sort(SortKey), + /// Put column `kind` before (or after) column `to`. + Move { + kind: SortKey, + to: SortKey, + after: bool, + }, + Reset, +} + +/// The Combine files tab. It shows in the Home slot (`active == None`) while `focused`. +#[derive(Clone, Debug, Default)] +pub struct CombineTab { + pub open: bool, + pub focused: bool, + /// The selected rows, by [`CombineFile::id`], so a selection survives sorting and moving. + pub selected: BTreeSet, + /// Where a Shift+click or Shift+arrow range starts, and where it ends. + anchor: Option, + cursor: Option, + /// The column the list is sorted by and whether ascending; cleared when rows are moved. + pub sort: Option<(SortKey, bool)>, + undo: Vec, + redo: Vec, + /// The list as it was when a Pages field took the keyboard, recorded on its first change. + range_edit: Option, + next_id: u64, + /// Bumped to make the table take its column widths afresh (columns moved or reset, or the + /// window resized while the widths follow it). + layout: u64, + /// The width the columns were last fitted to. + fitted: f32, + /// The password box of Unlock…, while it shows. + unlock: Option, +} + +/// A password: kept in memory only, never printed (debug output ends up in logs). +#[derive(Clone, Default)] +pub struct Secret(String); + +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(if self.0.is_empty() { "\"\"" } else { "…" }) + } +} + +/// Which password a protected file still needs before it can be combined. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Lock { + /// The password that opens it. + Open, + /// It opens, but only its permissions (owner) password allows copying its pages. + Permissions, +} + +/// Unlock…'s password box: the rows it unlocks and what has been typed. +#[derive(Clone, Debug)] +struct UnlockPrompt { + rows: Vec, + input: Secret, + show: bool, + error: Option, + /// Where the box opens (below the link or button that asked for it). + at: egui::Pos2, + focused: bool, +} + +/// What can be known about a file up front, opened with a password or not. +struct Assessed { + pages: usize, + problem: Option, + lock: Option, + notes: Vec, +} + +enum AssessError { + WrongPassword, + Unreadable(String), +} + +/// Read `bytes` (with `password`, if given) for the list: pages, what stops it being combined +/// and which password would help, and warnings. +fn assess(bytes: &Arc>, password: Option<&str>) -> Result { + // The engine that combines decides whether the password works; the inspector (which may + // not accept every password the engine does, e.g. an RC4 owner password) adds the details. + let check = pdfcraft_engine::combine_source_check(bytes, password); + if check == Err(SourceProblem::WrongPassword) { + return Err(AssessError::WrongPassword); + } + let info = pdfcraft_render::inspect(bytes.clone(), password); + if let (Err(SourceProblem::Unreadable(e)), Err(_)) = (&check, &info) { + return Err(AssessError::Unreadable(e.clone())); + } + let info = info.ok(); + let pages = match &info { + Some(i) => i.pages.len(), + None => pdfcraft_engine::source_page_count(bytes, password).unwrap_or(0), + }; + let encrypted = password.is_some() || info.as_ref().is_some_and(|i| i.encrypted); + let warnings = info.map(|i| i.warnings).unwrap_or_default(); + let (problem, lock) = match check { + Ok(()) => (None, None), + Err(SourceProblem::Password) => (Some(tl!("Password-protected").to_string()), Some(Lock::Open)), + Err(SourceProblem::WrongPassword) => return Err(AssessError::WrongPassword), + Err(SourceProblem::NotPermitted) if password.is_some() => ( + Some(tl!("Opened, but its security settings don't allow combining. Enter the permissions password.").to_string()), + Some(Lock::Permissions), + ), + Err(SourceProblem::NotPermitted) => (Some(tl!("Its security settings don't allow copying pages").to_string()), Some(Lock::Permissions)), + Err(SourceProblem::Unreadable(e)) => (Some(crate::i18n::fmt(tl!("Can't be read: {e}"), &[("e", &e)])), None), + }; + let mut notes = Vec::new(); + // The combined file is a new, unprotected document: protection doesn't carry over. + if encrypted && problem.is_none() { + notes.push(if password.is_some() { + tl!("Unlocked: the combined file won't be password-protected").to_string() + } else { + tl!("Protected: the combined file won't keep its security settings").to_string() + }); + } + notes.extend(warnings.into_iter().take(5)); + Ok(Assessed { pages, problem, lock, notes }) +} + +#[derive(Clone, Debug)] +struct Snapshot { + files: Vec, + selected: BTreeSet, + sort: Option<(SortKey, bool)>, +} + +/// A file arriving in the list (picked, dropped or already open). +pub(crate) struct Incoming { + pub name: String, + pub bytes: Arc>, + pub modified: Option, + /// Something to tell the user about where it came from. + pub note: Option, +} #[derive(Clone, Debug)] pub struct CombineFile { + /// Stable while the file is in the list (selection, undo). + pub id: u64, pub name: String, pub bytes: Arc>, pub pages: usize, /// The pages to take ("" = all). pub range: String, + /// When the file was last modified (desktop picks and drops only). + pub modified: Option, + /// Why the file can't be combined (password, security settings), if so. + pub problem: Option, + /// The password that would let it be combined, while one is needed. + pub lock: Option, + /// The password it was unlocked with. + password: Option, + /// Warnings that don't stop it being combined. + pub notes: Vec, + /// Where it came from, shown among the warnings (e.g. an open document's unsaved changes). + origin_note: Option, + /// The range last checked and the result: the number of pages it takes, or why it is wrong. + checked: Option<(String, Result)>, +} + +impl CombineFile { + fn take(&mut self, a: Assessed, password: Option) { + self.pages = a.pages; + self.problem = a.problem; + self.lock = a.lock; + self.password = password; + self.notes = self.origin_note.iter().cloned().chain(a.notes).collect(); + self.checked = None; + } + + /// The number of pages `range` takes, or why it can't be used. Checked again only when the + /// range changes. + pub fn selection(&mut self) -> Result { + if let Some((range, result)) = &self.checked + && *range == self.range + { + return result.clone(); + } + let result = if self.range.trim().is_empty() { + Ok(self.pages) + } else { + match pdfcraft_engine::print::select_pages(self.pages, Some(&self.range), &[], pdfcraft_engine::print::Subset::All, false) { + Ok(p) if p.is_empty() => Err(tl!("No pages selected").to_string()), + Ok(p) => Ok(p.len()), + Err(e) => Err(e.to_string()), + } + }; + self.checked = Some((self.range.clone(), result.clone())); + result + } + + /// 0 = can't be combined, 1 = has warnings, 2 = fine: the Warnings column's sort order. + fn severity(&mut self) -> u8 { + if self.problem.is_some() || self.selection().is_err() { + 0 + } else if !self.notes.is_empty() { + 1 + } else { + 2 + } + } } enum RowAction { - Up(usize), - Down(usize), - Remove(usize), + /// A click on row `i` (Ctrl/⌘ toggles it, Shift selects up to it). + Click(usize, Modifiers), + SelectAll, + /// Up/Down arrow; with Shift the selection grows. + Step { + down: bool, + extend: bool, + }, + MoveUp, + MoveDown, + Remove, + /// Row `from` (and the rest of the selection, if it is selected) dropped on row `to`. + Drop { + from: usize, + to: usize, + }, + Sort(SortKey), + /// Unlock… on row `i` (and the rest of the selection, if it is selected), or on the + /// selection (`None`); the password box opens at the position. + Unlock(Option, egui::Pos2), } -pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { - ui.label(egui::RichText::new(tl!("Combine files")).font(theme::semibold(18.0))); - ui.add_space(4.0); - ui.label(egui::RichText::new(tl!("Files are combined in this order. Leave Pages empty to take every page.")).small().color(t.text_faint)); - ui.add_space(8.0); - let mut action = None; +/// What the table reports besides row actions. +#[derive(Default)] +struct TableEvents { + action: Option, + range_focused: bool, + range_changed: bool, +} + +/// The Combine files page. +pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let files_hovering = ui.ctx().input(|i| !i.raw.hovered_files.is_empty()); + // Ranges are checked before anything below reads them. + let checks: Vec> = app.combine_draft.iter_mut().map(CombineFile::selection).collect(); let n = app.combine_draft.len(); - egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| { - egui::Grid::new("combine-files").num_columns(4).min_col_width(40.0).spacing([10.0, 6.0]).striped(true).show(ui, |ui| { - ui.label(egui::RichText::new(tl!("File")).color(t.text_muted)); - ui.label(egui::RichText::new(tl!("Pages")).color(t.text_muted)); - ui.label(""); - ui.label(""); - ui.end_row(); - for (i, f) in app.combine_draft.iter_mut().enumerate() { - ui.horizontal(|ui| { - ui.add(icons::image("file-text", 16.0, t.icon)); - ui.add(egui::Label::new(&f.name).truncate()); - let count = - if f.pages == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &f.pages.to_string())]) }; - ui.label(egui::RichText::new(count).small().color(t.text_faint)); - }); - ui.add_sized([120.0, 22.0], egui::TextEdit::singleline(&mut f.range).hint_text(tl!("All pages"))) - .on_hover_text(crate::i18n::fmt(tl!("Pages of {name} to combine, e.g. 1-3, 6"), &[("name", &f.name)])); - ui.horizontal(|ui| { - if ui.add_enabled_ui(i > 0, |ui| icons::button(ui, "chevron-up", 24.0, false, tl!("Move up"))).inner.clicked() { - action = Some(RowAction::Up(i)); - } - if ui.add_enabled_ui(i + 1 < n, |ui| icons::button(ui, "chevron-down", 24.0, false, tl!("Move down"))).inner.clicked() { - action = Some(RowAction::Down(i)); - } - }); - if icons::button(ui, "trash-2", 24.0, false, &crate::i18n::fmt(tl!("Remove {name}"), &[("name", &f.name)])).clicked() { - action = Some(RowAction::Remove(i)); + let ids: Vec = app.combine_draft.iter().map(|f| f.id).collect(); + app.combine_tab.selected.retain(|id| ids.contains(id)); + let selected: Vec = ids.iter().map(|id| app.combine_tab.selected.contains(id)).collect(); + let count = selected.iter().filter(|s| **s).count(); + let blocker = blocker(&app.combine_draft, &checks); + let mut events = TableEvents { action: keyboard(ui, n, count > 0), ..Default::default() }; + let mut combine = false; + + egui::Frame::NONE.inner_margin(egui::Margin::symmetric(28, 20)).show(ui, |ui| { + // Title and Combine. + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Combine files")).font(theme::semibold(20.0))); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let response = ui.add_enabled_ui(blocker.is_none(), |ui| widgets::icon_pill(ui, "files", tl!("Combine"), true)).inner; + if let Some(why) = &blocker { + response.clone().on_disabled_hover_text(why); } - ui.end_row(); + if response.clicked() { + combine = true; + } + }); + }); + ui.label(egui::RichText::new(tl!("Files are combined from top to bottom. Leave Pages empty to take every page.")).color(t.text_faint)); + ui.add_space(12.0); + + // Toolbar. + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + add_menu(app, ui); + let open_docs: Vec<(usize, String)> = + app.views.iter().enumerate().filter_map(|(i, v)| app.session.get(v.id).map(|d| (i, d.display_name()))).collect(); + let response = ui.add_enabled_ui(!open_docs.is_empty(), |ui| widgets::icon_pill(ui, "file-plus", tl!("Add open documents"), false)).inner; + response.clone().on_disabled_hover_text(tl!("No documents are open")); + egui::Popup::menu(&response).show(|ui| { + if open_docs.len() > 1 && ui.button(tl!("All open documents")).clicked() { + app.add_open_documents(&open_docs.iter().map(|(i, _)| *i).collect::>()); + ui.close(); + } + for (i, name) in &open_docs { + if ui.button(crate::bidi::visual(name).as_ref()).clicked() { + app.add_open_documents(&[*i]); + ui.close(); + } + } + }); + separator(ui, &t); + // Moves need a selected row with room to move; the buttons stay put either way. + let can_up = selected.iter().skip_while(|s| **s).any(|s| *s); + let can_down = selected.iter().rev().skip_while(|s| **s).any(|s| *s); + let why = tl!("Select a file first"); + let remove = match count { + 1 => { + let name = app.combine_draft.iter().find(|f| app.combine_tab.selected.contains(&f.id)).map_or("", |f| f.name.as_str()); + crate::i18n::fmt(tl!("Remove {name}"), &[("name", name)]) + } + 0 => tl!("Remove").to_string(), + k => crate::i18n::fmt(tl!("Remove {n} files"), &[("n", &k.to_string())]), + }; + if tool(ui, can_up, "chevron-up", tl!("Move up"), why).clicked() { + events.action = Some(RowAction::MoveUp); + } + if tool(ui, can_down, "chevron-down", tl!("Move down"), why).clicked() { + events.action = Some(RowAction::MoveDown); + } + if tool(ui, count > 0, "trash-2", &remove, why).clicked() { + events.action = Some(RowAction::Remove); + } + let locked = app.combine_draft.iter().any(|f| f.lock.is_some() && app.combine_tab.selected.contains(&f.id)); + let unlock = tool(ui, locked, "lock-open", tl!("Unlock…"), tl!("Select a password-protected file first")); + if unlock.clicked() { + events.action = Some(RowAction::Unlock(None, unlock.rect.left_bottom() + vec2(0.0, 6.0))); + } + separator(ui, &t); + let (undo, redo) = (!app.combine_tab.undo.is_empty(), !app.combine_tab.redo.is_empty()); + if tool(ui, undo, "undo-2", tl!("Undo"), tl!("Nothing to undo")).clicked() { + app.execute("edit.undo"); + } + if tool(ui, redo, "redo-2", tl!("Redo"), tl!("Nothing to redo")).clicked() { + app.execute("edit.redo"); + } + if count > 1 { + ui.add_space(6.0); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("{n} selected"), &[("n", &count.to_string())])).color(t.text_muted)); + } + }); + ui.add_space(10.0); + + // The list, in a card that highlights while files are dragged over the window. + let footer_h = 34.0; + let card_h = (ui.available_height() - footer_h).max(120.0); + let stroke = if files_hovering { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.border) }; + egui::Frame::NONE.fill(t.card).stroke(stroke).corner_radius(CornerRadius::same(t.radius)).inner_margin(egui::Margin::symmetric(8, 6)).show( + ui, + |ui| { + ui.set_min_height(card_h - 12.0); + ui.set_width(ui.available_width()); + if n == 0 { + empty_state(app, ui, &t, files_hovering); + } else { + let table_events = table(app, ui, &t, &checks, &selected); + events.range_focused |= table_events.range_focused; + events.range_changed |= table_events.range_changed; + if table_events.action.is_some() { + events.action = table_events.action; + } + } + }, + ); + + // Totals. + ui.add_space(8.0); + if n > 0 { + ui.horizontal(|ui| { + let pages: usize = checks.iter().filter_map(|c| c.as_ref().ok()).sum(); + let size: usize = app.combine_draft.iter().map(|f| f.bytes.len()).sum(); + let files = if n == 1 { tl!("1 file").to_string() } else { crate::i18n::fmt(tl!("{n} files"), &[("n", &n.to_string())]) }; + let pages = if pages == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &pages.to_string())]) }; + ui.label(egui::RichText::new(format!("{files} · {pages} · {}", crate::panels::human_size(size))).color(t.text_muted)); + let bad = app.combine_draft.iter().zip(&checks).filter(|(f, c)| f.problem.is_some() || c.is_err()).count(); + if bad > 0 { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let text = if bad == 1 { + tl!("1 file needs attention").to_string() + } else { + crate::i18n::fmt(tl!("{n} files need attention"), &[("n", &bad.to_string())]) + }; + ui.label(egui::RichText::new(text).color(ERROR)); + }); + } + }); + } + }); + + // A Pages field taking the keyboard remembers the list; its first change makes the undo step. + if events.range_focused { + app.combine_tab.range_edit = Some(app.combine_snapshot()); + } + if events.range_changed + && let Some(before) = app.combine_tab.range_edit.take() + { + app.combine_push_undo(before); + } + unlock_prompt(app, ui.ctx()); + if let Some(action) = events.action { + app.combine_apply(action); + } + if combine && blocker.is_none() { + app.combine_staged(); + } +} + +/// Unlock…'s password box, floating below what opened it. Enter unlocks, Escape or a click +/// elsewhere closes it. +fn unlock_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { + let Some(mut prompt) = app.combine_tab.unlock.take() else { return }; + let t = Tokens::get(ctx); + let files: Vec<&CombineFile> = app.combine_draft.iter().filter(|f| prompt.rows.contains(&f.id)).collect(); + let title = match files.as_slice() { + [one] => crate::i18n::fmt(tl!("Unlock {name}"), &[("name", &one.name)]), + many => crate::i18n::fmt(tl!("Unlock {n} files"), &[("n", &many.len().to_string())]), + }; + let explain = if files.iter().all(|f| f.lock == Some(Lock::Permissions)) { + tl!("Enter the permissions password to allow combining") + } else { + tl!("Enter the password that opens it") + }; + let batch = files.len() > 1; + let (mut submit, mut cancel) = (false, false); + let area = egui::Area::new(egui::Id::new("combine-unlock")).order(egui::Order::Foreground).fixed_pos(prompt.at).show(ctx, |ui| { + egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(14)).show(ui, |ui| { + ui.set_width(340.0); + ui.horizontal(|ui| { + ui.add(icons::image("lock", 16.0, t.icon)); + ui.add(egui::Label::new(egui::RichText::new(&title).font(theme::semibold(14.0))).truncate()); + }); + ui.add_space(4.0); + ui.label(egui::RichText::new(explain).color(t.text_muted)); + if batch { + ui.label(egui::RichText::new(tl!("The same password is tried on each file.")).small().color(t.text_faint)); + } + ui.add_space(8.0); + ui.horizontal(|ui| { + let field = + ui.add(egui::TextEdit::singleline(&mut prompt.input.0).password(!prompt.show).hint_text(tl!("Password")).desired_width(290.0)); + if !prompt.focused { + field.request_focus(); + prompt.focused = true; + } + if field.changed() { + prompt.error = None; + } + if field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + submit = true; + } + let (icon, tip) = if prompt.show { ("eye-off", tl!("Hide password")) } else { ("eye", tl!("Show password")) }; + if icons::button(ui, icon, 26.0, false, tip).clicked() { + prompt.show = !prompt.show; + } + }); + if let Some(e) = &prompt.error { + ui.label(egui::RichText::new(e).color(ERROR)); + } + ui.add_space(6.0); + ui.label(egui::RichText::new(tl!("The password is only used to read the file; it isn't saved.")).small().color(t.text_faint)); + ui.add_space(8.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add_enabled_ui(!prompt.input.0.is_empty(), |ui| widgets::pill_button(ui, tl!("Unlock"), true)).inner.clicked() { + submit = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + cancel = true; + } + }); + }); + }); + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) || area.response.clicked_elsewhere() { + cancel = true; + } + if submit && !prompt.input.0.is_empty() { + let rows = prompt.rows.clone(); + if app.combine_unlock(&rows, &prompt.input.0) { + return; + } + prompt.error = Some(tl!("Wrong password").to_string()); + prompt.focused = false; + } + if !cancel { + app.combine_tab.unlock = Some(prompt); + } +} + +fn separator(ui: &mut egui::Ui, t: &Tokens) { + ui.add_space(4.0); + ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(4.0), Stroke::new(1.0, t.divider)); + ui.add_space(6.0); +} + +/// A toolbar icon button, disabled with a reason. +fn tool(ui: &mut egui::Ui, enabled: bool, icon: &str, label: &str, disabled_why: &str) -> egui::Response { + ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, label)).inner.on_disabled_hover_text(disabled_why) +} + +/// Why Combine can't run yet, if so. +fn blocker(files: &[CombineFile], checks: &[Result]) -> Option { + if files.len() < 2 { + return Some(tl!("Add at least two files").to_string()); + } + for (f, check) in files.iter().zip(checks) { + if let Some(p) = &f.problem { + return Some(crate::i18n::fmt(tl!("{name} can't be combined: {why}"), &[("name", &f.name), ("why", p)])); + } + if let Err(e) = check { + return Some(crate::i18n::fmt(tl!("Check the pages of {name}: {why}"), &[("name", &f.name), ("why", e)])); + } + } + None +} + +/// While no text field has the keyboard: Up/Down select (Shift extends), Alt+Up/Down move the +/// selection, Delete removes it, Ctrl/⌘+A selects every file. +fn keyboard(ui: &egui::Ui, n: usize, any_selected: bool) -> Option { + if n == 0 || ui.ctx().memory(|m| m.focused().is_some()) { + return None; + } + use egui::Key; + ui.ctx().input_mut(|i| { + if i.consume_key(Modifiers::COMMAND, Key::A) { + return Some(RowAction::SelectAll); + } + if any_selected { + if i.consume_key(Modifiers::ALT, Key::ArrowUp) { + return Some(RowAction::MoveUp); + } + if i.consume_key(Modifiers::ALT, Key::ArrowDown) { + return Some(RowAction::MoveDown); + } + if i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace) { + return Some(RowAction::Remove); + } + } + for (down, key) in [(false, Key::ArrowUp), (true, Key::ArrowDown)] { + if i.consume_key(Modifiers::NONE, key) { + return Some(RowAction::Step { down, extend: false }); + } + if i.consume_key(Modifiers::SHIFT, key) { + return Some(RowAction::Step { down, extend: true }); + } + } + None + }) +} + +fn empty_state(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, files_hovering: bool) { + ui.vertical_centered(|ui| { + ui.add_space((ui.available_height() / 2.0 - 70.0).max(16.0)); + ui.add(icons::image("files", 40.0, if files_hovering { t.accent } else { t.text_faint })); + ui.add_space(10.0); + ui.label(egui::RichText::new(tl!("Add the PDFs to combine")).font(theme::medium(15.0))); + ui.add_space(4.0); + ui.label(egui::RichText::new(tl!("Drop PDFs or folders here, or choose them.")).color(t.text_faint)); + ui.add_space(12.0); + let folders = cfg!(not(target_arch = "wasm32")); + // Centre the buttons: measure them first. + let font = theme::medium(12.5); + let width = |s: &str| ui.fonts_mut(|f| f.layout_no_wrap(s.to_owned(), font.clone(), t.text).size().x); + let total = width(tl!("Add files…")) + 26.0 + if folders { width(tl!("Add folder…")) + 46.0 + 8.0 } else { 0.0 }; + ui.horizontal(|ui| { + ui.add_space(((ui.available_width() - total) / 2.0).max(0.0)); + ui.spacing_mut().item_spacing.x = 8.0; + if widgets::pill_button(ui, tl!("Add files…"), true).clicked() { + app.combine_dialog(); + } + if folders && widgets::icon_pill(ui, "folder", tl!("Add folder…"), false).clicked() { + app.combine_add_folder(false); } }); }); - match action { - Some(RowAction::Up(i)) => app.combine_draft.swap(i, i - 1), - Some(RowAction::Down(i)) => app.combine_draft.swap(i, i + 1), - Some(RowAction::Remove(i)) => { - app.combine_draft.remove(i); +} + +/// "Add files…" with a menu of the other ways to add: folders (desktop). +fn add_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { + if !cfg!(not(target_arch = "wasm32")) { + if widgets::icon_pill(ui, "plus", tl!("Add files…"), false).clicked() { + app.combine_dialog(); + } + return; + } + let (main, more) = widgets::split_pill(ui, "plus", tl!("Add files…"), tl!("More ways to add files")); + if main.clicked() { + app.combine_dialog(); + } + egui::Popup::menu(&more).show(|ui| { + ui.set_min_width(220.0); + let item = |ui: &mut egui::Ui, icon: &str, label: &str| { + ui.add(egui::Button::image_and_text(icons::image(icon, 15.0, ui.visuals().text_color()), label)) + }; + if item(ui, "file-plus", tl!("Add files…")).clicked() { + app.combine_dialog(); + ui.close(); + } + if item(ui, "folder", tl!("Add folder…")).on_hover_text(tl!("Every PDF in the folder")).clicked() { + app.combine_add_folder(false); + ui.close(); + } + if item(ui, "folder-open", tl!("Add folder and subfolders…")) + .on_hover_text(tl!("Every PDF in the folder and the folders inside it")) + .clicked() + { + app.combine_add_folder(true); + ui.close(); + } + }); +} + +/// The file table. +fn table(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, checks: &[Result], selected: &[bool]) -> TableEvents { + let mut events = TableEvents::default(); + // The same file twice is allowed (e.g. a cover sheet), but probably a mistake. + let twice: Vec = app + .combine_draft + .iter() + .map(|f| app.combine_draft.iter().filter(|g| g.name == f.name && g.bytes.len() == f.bytes.len()).count() > 1) + .collect(); + let dragging = egui::DragAndDrop::has_payload_of_type::(ui.ctx()); + // Until the user resizes a column, the widths follow the window. + let gap = ui.spacing().item_spacing.x; + let available = ui.available_width() - 18.0 - gap * 6.0; + if app.combine_columns.widths.is_none() && (available - app.combine_tab.fitted).abs() > 1.0 { + app.combine_tab.fitted = available; + app.combine_tab.layout = app.combine_tab.layout.wrapping_add(1); + } + let widths = app.combine_columns.resolved(available); + let rows = Rows { checks, selected, twice: &twice, dragging, sort: app.combine_tab.sort }; + let mut heading_event = None; + let mut shown = None; + egui::ScrollArea::horizontal() + .id_salt("combine-files-h") + .show(ui, |ui| table_body(app, ui, t, &rows, widths, &mut events, &mut heading_event, &mut shown)); + // A column resized by hand keeps its width from now on. + if let Some(shown) = shown + && shown.iter().zip(&widths).any(|(a, b)| (a - b).abs() > 0.5) + { + app.combine_columns.widths = Some(shown); + } + match heading_event { + Some(HeadingEvent::Sort(key)) => events.action = Some(RowAction::Sort(key)), + Some(HeadingEvent::Move { kind, to, after }) => { + let mut order: Vec = app.combine_columns.order.iter().copied().filter(|k| *k != kind).collect(); + let at = order.iter().position(|k| *k == to).map_or(order.len(), |p| if after { p + 1 } else { p }); + order.insert(at, kind); + if let Ok(order) = <[SortKey; 5]>::try_from(order) { + app.combine_columns.order = order; + app.combine_tab.layout = app.combine_tab.layout.wrapping_add(1); + } + } + Some(HeadingEvent::Reset) => { + app.combine_columns = Columns::default(); + app.combine_tab.fitted = 0.0; } None => {} } - ui.add_space(10.0); - let (mut go, mut cancel) = (false, false); - ui.horizontal(|ui| { - if widgets::pill_button(ui, tl!("Add files…"), false).clicked() { - app.combine_dialog(); + events +} + +/// What every row needs to know. +struct Rows<'a> { + checks: &'a [Result], + selected: &'a [bool], + twice: &'a [bool], + dragging: bool, + sort: Option<(SortKey, bool)>, +} + +/// A column heading: the whole cell sorts by its column when clicked, moves the column when +/// dragged, and offers Reset columns on a right-click. +fn heading(ui: &mut egui::Ui, t: &Tokens, key: SortKey, sort: Option<(SortKey, bool)>) -> Option { + let text = key.label(); + let rect = ui.max_rect(); + let response = ui.allocate_rect(rect, Sense::click_and_drag()); + let arrow = sort.filter(|(k, _)| *k == key).map(|(_, ascending)| ascending); + let state = match arrow { + Some(true) => tl!("sorted ascending"), + Some(false) => tl!("sorted descending"), + None => "", + }; + let a11y = if state.is_empty() { text.to_owned() } else { format!("{text}, {state}") }; + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &a11y)); + let being_dragged = egui::DragAndDrop::payload::(ui.ctx()).is_some_and(|p| p.0 == key); + if being_dragged || response.is_pointer_button_down_on() { + ui.painter().rect_filled(rect, CornerRadius::same(4), t.pressed); + } else if response.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover); + } + let colour = if arrow.is_some() { t.text } else { t.text_muted }; + let galley = ui.painter().layout_no_wrap(text.to_owned(), theme::medium(12.5), colour); + let text_pos = egui::pos2(rect.left() + 4.0, rect.center().y - galley.size().y / 2.0); + let text_w = galley.size().x; + ui.painter().with_clip_rect(rect).galley(text_pos, galley, colour); + if let Some(ascending) = arrow { + let r = Rect::from_center_size(egui::pos2(rect.left() + text_w + 14.0, rect.center().y), vec2(12.0, 12.0)); + icons::paint(ui, r, if ascending { "chevron-up" } else { "chevron-down" }, 12.0, t.text); + } + if response.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), HeadingDrag(key)); + } + // Another heading dragged over this one: a line where it would go. + let after = |r: &egui::Response| r.ctx.pointer_latest_pos().is_some_and(|p| p.x > rect.center().x); + if let Some(dragged) = response.dnd_hover_payload::() + && dragged.0 != key + { + let x = if after(&response) { rect.right() } else { rect.left() }; + ui.painter().with_clip_rect(rect.expand(3.0)).vline(x, rect.y_range(), Stroke::new(2.5, t.accent)); + } + let mut event = None; + if let Some(dragged) = response.dnd_release_payload::() + && dragged.0 != key + { + event = Some(HeadingEvent::Move { kind: dragged.0, to: key, after: after(&response) }); + } + let response = response + .on_hover_cursor(if being_dragged { egui::CursorIcon::Grabbing } else { egui::CursorIcon::PointingHand }) + .on_hover_text(tl!("Click to sort, drag to move the column. Drag its edge to resize it.")); + response.context_menu(|ui| { + if ui.button(tl!("Reset columns")).clicked() { + event = Some(HeadingEvent::Reset); + ui.close(); } - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - let ok = app.combine_draft.len() >= 2; - if ui - .add_enabled_ui(ok, |ui| widgets::pill_button(ui, tl!("Combine"), true)) - .inner - .on_disabled_hover_text(tl!("Add at least two files")) - .clicked() - { - go = true; + }); + if response.clicked() { + event = Some(HeadingEvent::Sort(key)); + } + event +} + +#[allow(clippy::too_many_arguments)] +fn table_body( + app: &mut PdfCraftApp, + ui: &mut egui::Ui, + t: &Tokens, + rows: &Rows, + widths: [f32; 5], + events: &mut TableEvents, + heading_event: &mut Option, + shown: &mut Option<[f32; 5]>, +) { + let n = app.combine_draft.len(); + let order = app.combine_columns.order; + // The heading being dragged follows the pointer. + if let (Some(dragged), Some(pos)) = (egui::DragAndDrop::payload::(ui.ctx()), ui.ctx().pointer_latest_pos()) { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("combine-heading-drag"))); + let galley = painter.layout_no_wrap(dragged.0.label().to_owned(), theme::medium(12.5), t.text); + let r = Rect::from_min_size(pos + vec2(10.0, 8.0), galley.size() + vec2(16.0, 8.0)); + painter.rect(r, CornerRadius::same(4), t.card, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + painter.galley(r.min + vec2(8.0, 4.0), galley, t.text); + } + let mut builder = TableBuilder::new(ui) + // Columns moved, reset or refitted: a new table state, built from `widths`. + .id_salt(("combine-files", app.combine_tab.layout)) + .striped(true) + .sense(Sense::click()) + .auto_shrink([false, true]) + .cell_layout(Layout::left_to_right(Align::Center)) + .column(Column::exact(18.0)); + for kind in order { + builder = builder.column(Column::initial(widths[kind.index()]).at_least(kind.min_width()).resizable(true).clip(true)); + } + builder + .header(26.0, |mut row| { + row.col(|_| {}); + for kind in order { + row.col(|ui| { + if let Some(e) = heading(ui, t, kind, rows.sort) { + *heading_event = Some(e); + } + }); } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - cancel = true; + }) + .body(|mut body| { + if !body.ui_mut().is_sizing_pass() { + let w = body.widths(); + let mut out = widths; + for (kind, width) in order.iter().zip(w.iter().skip(1)) { + out[kind.index()] = *width; + } + *shown = Some(out); + } + for i in 0..n { + let Some(f) = app.combine_draft.get_mut(i) else { break }; + let check = rows.checks.get(i).cloned().unwrap_or(Ok(f.pages)); + body.row(32.0, |mut row| { + row.set_selected(rows.selected.get(i).copied().unwrap_or(false)); + // Drag handle. + row.col(|ui| { + let id = ui.id().with(("combine-drag", f.id)); + ui.dnd_drag_source(id, i, |ui| { + let (rect, resp) = ui.allocate_exact_size(vec2(12.0, 18.0), Sense::hover()); + grip(ui, rect, if resp.hovered() || rows.dragging { t.text_muted } else { t.text_faint }); + }) + .response + .on_hover_cursor(egui::CursorIcon::Grab) + .on_hover_text(tl!("Drag to reorder")); + }); + for kind in order { + row.col(|ui| match kind { + SortKey::Name => { + ui.add(icons::image("file-text", 16.0, t.icon)); + ui.add(egui::Label::new(crate::bidi::visual(&f.name).as_ref()).truncate().selectable(false)); + } + // The range field and how many pages it takes. + SortKey::Pages => { + let edit = ui.add_enabled( + f.problem.is_none(), + egui::TextEdit::singleline(&mut f.range).hint_text(tl!("All pages")).desired_width(88.0), + ); + // A visible field on plain and striped rows alike; red when the range is wrong. + let border = if check.is_err() { Stroke::new(1.5, ERROR) } else { Stroke::new(1.0, t.border) }; + ui.painter().rect_stroke(edit.rect, CornerRadius::same(4), border, egui::StrokeKind::Inside); + if let Err(e) = &check { + edit.clone().on_hover_text(e); + } else { + edit.clone() + .on_hover_text(crate::i18n::fmt(tl!("Pages of {name} to combine, e.g. 1-3, 6"), &[("name", &f.name)])); + } + if edit.gained_focus() { + events.range_focused = true; + events.action = Some(RowAction::Click(i, Modifiers::NONE)); + } + if edit.changed() { + events.range_changed = true; + } + let count = match (&check, f.problem.is_some()) { + (_, true) => "—".to_string(), + (Ok(k), _) => crate::i18n::fmt(tl!("{k} of {n}"), &[("k", &k.to_string()), ("n", &f.pages.to_string())]), + (Err(_), _) => crate::i18n::fmt(tl!("? of {n}"), &[("n", &f.pages.to_string())]), + }; + ui.label(egui::RichText::new(count).small().color(if check.is_err() { ERROR } else { t.text_faint })); + } + SortKey::Size => { + ui.label(egui::RichText::new(crate::panels::human_size(f.bytes.len())).color(t.text_muted)); + } + SortKey::Modified => match f.modified { + Some(m) => { + ui.label(egui::RichText::new(ago(m)).color(t.text_muted)).on_hover_text(utc_stamp(m)); + } + None => { + ui.label(egui::RichText::new("—").color(t.text_faint)); + } + }, + // Blocking problems first, in red. + SortKey::Warnings => { + let mut errors: Vec = f.problem.iter().cloned().collect(); + if let Err(e) = &check { + errors.push(e.clone()); + } + let mut warnings = f.notes.clone(); + if rows.twice.get(i).copied().unwrap_or(false) { + warnings.push(tl!("Added more than once").to_string()); + } + let (icon, colour, first) = match (errors.first(), warnings.first()) { + (Some(e), _) => ("circle-x", ERROR, e.clone()), + (None, Some(w)) => ("triangle-alert", WARNING, w.clone()), + (None, None) => return, + }; + let all = errors.iter().chain(&warnings).cloned().collect::>().join("\n"); + ui.add(icons::image(icon, 15.0, colour)); + if f.lock.is_some() { + let link = + ui.add(egui::Link::new(egui::RichText::new(tl!("Unlock…")).color(t.accent))).on_hover_text(match f.lock { + Some(Lock::Permissions) => tl!("Enter the permissions password to allow combining"), + _ => tl!("Enter the password that opens it"), + }); + if link.clicked() { + events.action = Some(RowAction::Unlock(Some(i), link.rect.left_bottom() + vec2(0.0, 6.0))); + } + } + ui.add( + egui::Label::new(egui::RichText::new(first).color(if colour == ERROR { ERROR } else { t.text })) + .truncate() + .selectable(false), + ) + .on_hover_text(all); + } + }); + } + let response = row.response(); + if f.lock.is_some() { + let at = response.rect.left_bottom(); + response.context_menu(|ui| { + if ui + .add(egui::Button::image_and_text(icons::image("lock-open", 15.0, ui.visuals().text_color()), tl!("Unlock…"))) + .clicked() + { + events.action = Some(RowAction::Unlock(Some(i), at)); + ui.close(); + } + }); + } + if response.clicked() { + let modifiers = response.ctx.input(|i| i.modifiers); + events.action = Some(RowAction::Click(i, modifiers)); + } + // A row dragged over this one goes where the line is drawn. + if let Some(from) = response.dnd_hover_payload::().map(|p| *p) { + let r = response.rect; + let y = if from > i { r.top() } else { r.bottom() }; + if from != i { + response.ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("combine-drop-line"))).hline( + r.x_range(), + y, + Stroke::new(2.0, t.accent), + ); + } + } + if let Some(from) = response.dnd_release_payload::().map(|p| *p) { + events.action = Some(RowAction::Drop { from, to: i }); + } + }); } }); - }); - (go, cancel) +} + +/// The PDFs in `dir` (and, if `recursive`, the folders inside it, not following links), in +/// natural order, at most [`MAX_FOLDER_FILES`]; and whether there were more. +#[cfg(not(target_arch = "wasm32"))] +fn pdfs_in(dir: &std::path::Path, recursive: bool) -> (Vec, bool) { + let mut found = Vec::new(); + let mut more = false; + let mut folders = vec![(dir.to_path_buf(), 0usize)]; + 'walk: while let Some((folder, depth)) = folders.pop() { + let Ok(entries) = std::fs::read_dir(&folder) else { continue }; + for entry in entries.flatten() { + let Ok(kind) = entry.file_type() else { continue }; + let path = entry.path(); + if kind.is_dir() { + let hidden = entry.file_name().to_string_lossy().starts_with('.'); + if recursive && !hidden && depth < MAX_FOLDER_DEPTH { + folders.push((path, depth + 1)); + } + } else if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("pdf")) && path.is_file() { + if found.len() >= MAX_FOLDER_FILES { + more = true; + break 'walk; + } + found.push(path); + } + } + } + found.sort_by(|a, b| natural(&a.to_string_lossy(), &b.to_string_lossy())); + (found, more) +} + +/// File names in the order people expect: case-insensitive, with numbers by value +/// ("scan2" before "scan10"). +fn natural(a: &str, b: &str) -> Ordering { + let (mut a, mut b) = (a.chars().peekable(), b.chars().peekable()); + loop { + match (a.peek().copied(), b.peek().copied()) { + (None, None) => return Ordering::Equal, + (None, Some(_)) => return Ordering::Less, + (Some(_), None) => return Ordering::Greater, + (Some(x), Some(y)) if x.is_ascii_digit() && y.is_ascii_digit() => { + let take = |it: &mut std::iter::Peekable| { + let mut digits = String::new(); + while let Some(c) = it.peek().copied().filter(char::is_ascii_digit) { + digits.push(c); + it.next(); + } + digits.trim_start_matches('0').to_owned() + }; + let (x, y) = (take(&mut a), take(&mut b)); + // Without leading zeros, a longer run of digits is a larger number. + match x.len().cmp(&y.len()).then_with(|| x.cmp(&y)) { + Ordering::Equal => {} + other => return other, + } + } + (Some(x), Some(y)) => { + match x.to_lowercase().cmp(y.to_lowercase()) { + Ordering::Equal => {} + other => return other, + } + a.next(); + b.next(); + } + } + } +} + +/// Six dots: the drag handle. +fn grip(ui: &egui::Ui, rect: Rect, colour: Color32) { + for dx in [-2.5, 2.5] { + for dy in [-5.0, 0.0, 5.0] { + ui.painter().circle_filled(rect.center() + vec2(dx, dy), 1.4, colour); + } + } +} + +/// How long ago `time` was ("5 min ago"); a date after a week. Relative times need no time zone. +fn ago(time: SystemTime) -> String { + let secs = SystemTime::now().duration_since(time).map(|d| d.as_secs()).unwrap_or(0); + let (mins, hours, days) = (secs / 60, secs / 3600, secs / 86_400); + match () { + _ if mins == 0 => tl!("just now").to_string(), + _ if hours == 0 => crate::i18n::fmt(tl!("{n} min ago"), &[("n", &mins.to_string())]), + _ if days == 0 => crate::i18n::fmt(tl!("{n} h ago"), &[("n", &hours.to_string())]), + _ if days == 1 => tl!("yesterday").to_string(), + _ if days < 7 => crate::i18n::fmt(tl!("{n} days ago"), &[("n", &days.to_string())]), + _ => utc_stamp(time).get(..10).unwrap_or_default().to_string(), + } +} + +/// `time` as "2026-10-08 14:05 UTC". +fn utc_stamp(time: SystemTime) -> String { + let secs = time.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let days = i64::try_from(secs / 86_400).unwrap_or(0); + let rem = secs % 86_400; + // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(m <= 2); + format!("{y:04}-{m:02}-{d:02} {:02}:{:02} UTC", rem / 3600, rem % 3600 / 60) } impl PdfCraftApp { - /// Add picked files to the Combine files list (and show it). - pub(crate) fn stage_combine(&mut self, files: Vec<(String, Vec)>) { - for (name, bytes) in files { - let bytes = Arc::new(bytes); - match pdfcraft_render::inspect(bytes.clone(), None) { - Ok(info) => self.combine_draft.push(CombineFile { name, bytes, pages: info.pages.len(), range: String::new() }), - Err(e) => self.notify_fmt("Couldn't add {name}: {e}", &[("name", &name), ("e", &e.to_string())]), - } - } - self.dialog = Some(Dialog::Combine); + /// Show the Combine files tab (File ▸ Combine files…), opening it if needed. + pub fn open_combine_tab(&mut self) { + self.combine_tab.open = true; + self.combine_tab.focused = true; + self.active = None; } - /// Combine the listed files into a new, unsaved document. + /// Whether the Combine files tab is the one showing. + pub fn combine_showing(&self) -> bool { + self.active.is_none() && self.combine_tab.open && self.combine_tab.focused + } + + /// The selected rows, top to bottom (tests and automation). + pub fn combine_selection(&self) -> Vec { + self.combine_draft.iter().enumerate().filter(|(_, f)| self.combine_tab.selected.contains(&f.id)).map(|(i, _)| i).collect() + } + + /// Select these rows (tests and automation). + pub fn select_combine_rows(&mut self, rows: &[usize]) { + self.combine_tab.selected = rows.iter().filter_map(|i| self.combine_draft.get(*i)).map(|f| f.id).collect(); + let last = rows.last().and_then(|i| self.combine_draft.get(*i)).map(|f| f.id); + (self.combine_tab.anchor, self.combine_tab.cursor) = (last, last); + } + + fn combine_snapshot(&self) -> Snapshot { + Snapshot { files: self.combine_draft.clone(), selected: self.combine_tab.selected.clone(), sort: self.combine_tab.sort } + } + + fn combine_restore(&mut self, s: Snapshot) { + self.combine_draft = s.files; + self.combine_tab.selected = s.selected; + self.combine_tab.sort = s.sort; + self.combine_tab.range_edit = None; + // A Pages field with the keyboard would otherwise keep typing into the restored list + // without an undo step. + if let Some(ctx) = &self.ctx { + ctx.memory_mut(|m| m.stop_text_input()); + } + } + + fn combine_push_undo(&mut self, before: Snapshot) { + let undo = &mut self.combine_tab.undo; + undo.push(before); + if undo.len() > HISTORY { + undo.remove(0); + } + self.combine_tab.redo.clear(); + } + + /// Remember the list before a change, for Undo. + fn combine_record(&mut self) { + let now = self.combine_snapshot(); + self.combine_push_undo(now); + self.combine_tab.range_edit = None; + } + + pub(crate) fn combine_can_undo(&self, undo: bool) -> bool { + if undo { !self.combine_tab.undo.is_empty() } else { !self.combine_tab.redo.is_empty() } + } + + /// Edit ▸ Undo / Redo (⌘Z, ⇧⌘Z) while the Combine files tab shows. + pub(crate) fn combine_history_step(&mut self, undo: bool) { + // ⌘Z in the password box is not about the list. + if self.combine_tab.unlock.is_some() { + return; + } + let (from, to) = + if undo { (&mut self.combine_tab.undo, &mut self.combine_tab.redo) } else { (&mut self.combine_tab.redo, &mut self.combine_tab.undo) }; + let Some(state) = from.pop() else { return }; + let now = Snapshot { files: self.combine_draft.clone(), selected: self.combine_tab.selected.clone(), sort: self.combine_tab.sort }; + to.push(now); + self.combine_restore(state); + } + + fn combine_sort(&mut self, key: SortKey, ascending: bool) { + let list = &mut self.combine_draft; + let mut keyed: Vec<(u8, CombineFile)> = list.drain(..).map(|mut f| (f.severity(), f)).collect(); + keyed.sort_by(|(sa, a), (sb, b)| { + let order = match key { + SortKey::Name => natural(&a.name, &b.name), + SortKey::Pages => a.pages.cmp(&b.pages), + SortKey::Size => a.bytes.len().cmp(&b.bytes.len()), + SortKey::Modified => match (a.modified, b.modified) { + (Some(x), Some(y)) => x.cmp(&y), + // Unknown times last, whichever the direction. + (x, y) => return x.is_none().cmp(&y.is_none()).then_with(|| natural(&a.name, &b.name)), + }, + SortKey::Warnings => sa.cmp(sb), + }; + let order = if ascending { order } else { order.reverse() }; + order.then_with(|| natural(&a.name, &b.name)) + }); + *list = keyed.into_iter().map(|(_, f)| f).collect(); + self.combine_tab.sort = Some((key, ascending)); + } + + fn combine_apply(&mut self, action: RowAction) { + let ids: Vec = self.combine_draft.iter().map(|f| f.id).collect(); + let n = ids.len(); + let index_of = |id: Option| id.and_then(|id| ids.iter().position(|x| *x == id)); + let tab = &mut self.combine_tab; + match action { + RowAction::Click(i, m) => { + let Some(&id) = ids.get(i) else { return }; + if m.shift { + let from = index_of(tab.anchor).unwrap_or(i); + let range = ids.get(from.min(i)..=from.max(i)).unwrap_or_default(); + if !m.command { + tab.selected.clear(); + } + tab.selected.extend(range.iter().copied()); + tab.cursor = Some(id); + } else if m.command { + if !tab.selected.remove(&id) { + tab.selected.insert(id); + } + (tab.anchor, tab.cursor) = (Some(id), Some(id)); + } else { + tab.selected = BTreeSet::from([id]); + (tab.anchor, tab.cursor) = (Some(id), Some(id)); + } + } + RowAction::SelectAll => { + tab.selected = ids.iter().copied().collect(); + } + RowAction::Step { down, extend } => { + let next = match index_of(tab.cursor) { + Some(c) if down => (c + 1).min(n.saturating_sub(1)), + Some(c) => c.saturating_sub(1), + None if down => 0, + None => n.saturating_sub(1), + }; + let Some(&id) = ids.get(next) else { return }; + if extend { + let from = index_of(tab.anchor).unwrap_or(next); + tab.selected = ids.get(from.min(next)..=from.max(next)).unwrap_or_default().iter().copied().collect(); + } else { + tab.selected = BTreeSet::from([id]); + tab.anchor = Some(id); + } + tab.cursor = Some(id); + } + RowAction::MoveUp | RowAction::MoveDown => { + let up = matches!(action, RowAction::MoveUp); + let selected: Vec = ids.iter().map(|id| tab.selected.contains(id)).collect(); + let movable = + if up { selected.iter().skip_while(|s| **s).any(|s| *s) } else { selected.iter().rev().skip_while(|s| **s).any(|s| *s) }; + if !movable { + return; + } + self.combine_record(); + let list = &mut self.combine_draft; + let sel = &self.combine_tab.selected; + // Each selected file steps past its unselected neighbour; blocks move together. + if up { + for i in 1..list.len() { + if sel.contains(&list[i].id) && !sel.contains(&list[i - 1].id) { + list.swap(i, i - 1); + } + } + } else { + for i in (0..list.len().saturating_sub(1)).rev() { + if sel.contains(&list[i].id) && !sel.contains(&list[i + 1].id) { + list.swap(i, i + 1); + } + } + } + self.combine_tab.sort = None; + } + RowAction::Remove => { + let Some(first) = ids.iter().position(|id| tab.selected.contains(id)) else { return }; + self.combine_record(); + let sel = std::mem::take(&mut self.combine_tab.selected); + self.combine_draft.retain(|f| !sel.contains(&f.id)); + // The file that took the first removed one's place is selected next. + let next = self.combine_draft.get(first).or(self.combine_draft.last()).map(|f| f.id); + self.combine_tab.selected = next.into_iter().collect(); + (self.combine_tab.anchor, self.combine_tab.cursor) = (next, next); + } + RowAction::Drop { from, to } => { + let (Some(&dragged), Some(&target)) = (ids.get(from), ids.get(to)) else { return }; + // Dragging a selected file takes the whole selection along. + let moving: BTreeSet = if tab.selected.contains(&dragged) { tab.selected.clone() } else { BTreeSet::from([dragged]) }; + if moving.contains(&target) { + return; + } + self.combine_record(); + let list = &mut self.combine_draft; + let (taken, mut rest): (Vec<_>, Vec<_>) = list.drain(..).partition(|f| moving.contains(&f.id)); + let at = rest.iter().position(|f| f.id == target).map_or(rest.len(), |p| if from < to { p + 1 } else { p }); + rest.splice(at..at, taken); + *list = rest; + self.combine_tab.selected = moving; + self.combine_tab.sort = None; + } + RowAction::Unlock(row, at) => { + let locked: BTreeSet = self.combine_draft.iter().filter(|f| f.lock.is_some()).map(|f| f.id).collect(); + let rows: Vec = match row.and_then(|i| ids.get(i).copied()) { + // A selected row brings the rest of the selection. + Some(id) if tab.selected.contains(&id) => { + ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect() + } + Some(id) => { + tab.selected = BTreeSet::from([id]); + (tab.anchor, tab.cursor) = (Some(id), Some(id)); + vec![id] + } + None => ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect(), + }; + self.combine_ask_password(rows, at); + } + RowAction::Sort(key) => { + let ascending = !matches!(tab.sort, Some((k, true)) if k == key); + self.combine_record(); + self.combine_sort(key, ascending); + } + } + } + + /// Close the Combine files tab and forget its list; the last document shows, if any. + pub fn close_combine_tab(&mut self) { + let showing = self.combine_showing(); + self.combine_tab = CombineTab::default(); + self.combine_draft.clear(); + if showing { + self.active = self.views.len().checked_sub(1); + } + } + + /// Add picked files to the Combine files list (and show it). + pub(crate) fn stage_combine(&mut self, files: Vec<(String, Vec)>) { + let incoming = files.into_iter().map(|(name, bytes)| Incoming { name, bytes: Arc::new(bytes), modified: None, note: None }).collect(); + self.stage_combine_with(incoming); + } + + /// Add files to the Combine files list, with what can be known about them up front. + pub(crate) fn stage_combine_with(&mut self, files: Vec) { + let mut added = Vec::new(); + for Incoming { name, bytes, modified, note } in files { + let assessed = match assess(&bytes, None) { + Ok(a) => a, + Err(AssessError::Unreadable(e)) => { + self.notify_fmt("Couldn't add {name}: {e}", &[("name", &name), ("e", &e)]); + continue; + } + Err(AssessError::WrongPassword) => { + self.notify_fmt("Couldn't add {name}: {e}", &[("name", &name), ("e", "the password is wrong")]); + continue; + } + }; + let mut f = CombineFile { + id: 0, + name, + bytes, + pages: 0, + range: String::new(), + modified, + problem: None, + lock: None, + password: None, + notes: Vec::new(), + origin_note: note, + checked: None, + }; + f.take(assessed, None); + added.push(f); + } + if !added.is_empty() { + self.combine_record(); + for mut f in added { + f.id = self.combine_tab.next_id; + self.combine_tab.next_id = self.combine_tab.next_id.wrapping_add(1); + self.combine_draft.push(f); + } + // A sorted list stays sorted; new files simply join the end otherwise. + if let Some((key, ascending)) = self.combine_tab.sort { + self.combine_sort(key, ascending); + } + } + self.open_combine_tab(); + } + + /// Open Unlock…'s password box for `rows`, at `at`. + fn combine_ask_password(&mut self, rows: Vec, at: egui::Pos2) { + if rows.is_empty() { + return; + } + self.combine_tab.unlock = Some(UnlockPrompt { rows, input: Secret::default(), show: false, error: None, at, focused: false }); + } + + /// Try `password` on the protected files `rows` (as one undo step). Returns false when it + /// opened none of them. + pub fn combine_unlock(&mut self, rows: &[u64], password: &str) -> bool { + let mut accepted = Vec::new(); + for id in rows { + let Some(f) = self.combine_draft.iter().find(|f| f.id == *id && f.lock.is_some()) else { continue }; + if let Ok(a) = assess(&f.bytes, Some(password)) { + accepted.push((*id, a)); + } + } + if accepted.is_empty() { + return false; + } + self.combine_record(); + let tried = rows.len(); + let mut unlocked = 0; + for (id, a) in accepted { + if let Some(f) = self.combine_draft.iter_mut().find(|f| f.id == id) { + f.take(a, Some(Secret(password.to_owned()))); + unlocked += usize::from(f.lock.is_none()); + } + } + if tried > 1 { + self.notify_fmt("Unlocked {n} of {m} files", &[("n", &unlocked.to_string()), ("m", &tried.to_string())]); + } + true + } + + /// Unlock… for the files the user named in tests and automation (by row). + pub fn combine_unlock_rows(&mut self, rows: &[usize], password: &str) -> bool { + let ids: Vec = rows.iter().filter_map(|i| self.combine_draft.get(*i)).map(|f| f.id).collect(); + self.combine_unlock(&ids, password) + } + + /// Add every PDF in a folder the user picks (and its subfolders, if `recursive`). + pub fn combine_add_folder(&mut self, recursive: bool) { + #[cfg(not(target_arch = "wasm32"))] + { + let title = if recursive { tl!("Add folder and subfolders") } else { tl!("Add folder") }; + let dialog = rfd::AsyncFileDialog::new().set_title(title); + self.ask_one(crate::pickers::Ask::Folder(dialog), None, move |app, dir| app.add_folder_to_combine(&dir, recursive)); + } + #[cfg(target_arch = "wasm32")] + let _ = recursive; + } + + /// Add the PDFs in `dir` to the Combine files list, as one undo step. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn add_folder_to_combine(&mut self, dir: &std::path::Path, recursive: bool) { + let (paths, more) = pdfs_in(dir, recursive); + let folder = dir.file_name().map_or_else(|| dir.to_string_lossy().into_owned(), |n| n.to_string_lossy().into_owned()); + if paths.is_empty() { + self.notify_fmt("No PDFs found in {folder}", &[("folder", &folder)]); + self.open_combine_tab(); + return; + } + self.use_paths(crate::files::FilePurpose::Combine, &paths); + if more { + self.notify_fmt("Added the first {n} PDFs in {folder}", &[("n", &MAX_FOLDER_FILES.to_string()), ("folder", &folder)]); + } + } + + /// Add documents open in tabs (by tab index), as they are now. + pub(crate) fn add_open_documents(&mut self, tabs: &[usize]) { + let mut incoming = Vec::new(); + for &i in tabs { + let Some(doc) = self.views.get(i).and_then(|v| self.session.get(v.id)) else { continue }; + #[cfg(not(target_arch = "wasm32"))] + let modified = if doc.dirty { None } else { doc.path.as_ref().and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok()) }; + #[cfg(target_arch = "wasm32")] + let modified = None; + let note = doc.dirty.then(|| tl!("Includes unsaved changes").to_string()); + let name = if doc.name.to_ascii_lowercase().ends_with(".pdf") { doc.name.clone() } else { format!("{}.pdf", doc.name) }; + incoming.push(Incoming { name, bytes: doc.bytes.clone(), modified, note }); + } + self.stage_combine_with(incoming); + } + + /// Combine the listed files into a new, unsaved document; the tab makes way for it. pub fn combine_staged(&mut self) { - let files = std::mem::take(&mut self.combine_draft); + let files = &self.combine_draft; if files.is_empty() { return; } let count = files.len(); let sources: Vec<(String, Arc>, Option)> = files - .into_iter() - .map(|f| (crate::files::strip_pdf(&f.name).to_string(), f.bytes, Some(f.range).filter(|r| !r.trim().is_empty()))) + .iter() + .map(|f| (crate::files::strip_pdf(&f.name).to_string(), f.bytes.clone(), Some(f.range.clone()).filter(|r| !r.trim().is_empty()))) .collect(); - match self.session.combine_ranges(&sources) { + let passwords: Vec> = files.iter().map(|f| f.password.as_ref().map(|p| p.0.as_str())).collect(); + match self.session.combine_unlocked(&sources, &passwords) { Ok(bytes) => { + self.close_combine_tab(); let message = crate::i18n::fmt(tl!("Combined {n} files"), &[("n", &count.to_string())]); self.open_created("Combined.pdf", bytes, &message) } + // The list stays, so the user can fix it. Err(e) => self.notify_fmt("Couldn't combine files: {e}", &[("e", &e.to_string())]), } } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 34e96c91..79953088 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -17,6 +17,10 @@ impl PdfCraftApp { /// Whether a registered command can run now. The engine judges the document (security, /// contents, undo history); view state it can't see is checked here. pub(crate) fn command_enabled(&self, spec: &CommandSpec) -> bool { + // Undo and Redo act on the Combine files list while its tab shows. + if self.combine_showing() && matches!(spec.needs, commands::Needs::Undo | commands::Needs::Redo) { + return self.combine_can_undo(spec.needs == commands::Needs::Undo); + } commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id)) && (spec.needs != commands::Needs::TwoPageView || self.active.and_then(|i| self.views.get(i)).is_some_and(crate::DocView::cover_applies)) } @@ -76,7 +80,7 @@ impl PdfCraftApp { let targets = active.map(|i| self.views[i].target_pages()).unwrap_or_default(); match id { "file.open" => self.open_dialog(), - "page.combine" => self.combine_dialog(), + "page.combine" => self.open_combine_tab(), "file.save" => { self.save_active(SaveTarget::InPlace); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a35ccfa3..f1243f2d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -54,7 +54,6 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { let mut a11y_now = false; let mut ocr_now = false; let mut compare_now = false; - let mut combine_now = false; let mut images_now = false; let mut stamp_now = false; let mut alt_now = false; @@ -843,16 +842,6 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { close = save || cancel; return; } - Dialog::Combine => { - ui.set_width(620.0); - let (go, cancel) = crate::combine_ui::body(ui, app, &t); - combine_now = go; - if cancel { - app.combine_draft.clear(); - } - close = go || cancel; - return; - } Dialog::PdfA => { close = crate::standards_ui::body(ui, app, &t); return; @@ -1288,9 +1277,6 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { if stamp_now { app.save_custom_stamp(); } - if combine_now { - app.combine_staged(); - } if images_now { app.finish_image_import(); } else if dialog == Dialog::CreateImages && app.dialog != Some(Dialog::CreateImages) { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 02378e64..27b26277 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -73,12 +73,21 @@ impl PdfCraftApp { } } + /// Undo the last change: to the Combine files list while its tab shows, else the document. pub fn undo(&mut self) { - self.history_step(true); + if self.combine_showing() { + self.combine_history_step(true); + } else { + self.history_step(true); + } } pub fn redo(&mut self) { - self.history_step(false); + if self.combine_showing() { + self.combine_history_step(false); + } else { + self.history_step(false); + } } fn history_step(&mut self, undo: bool) { diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index d4c492a1..9d4e10f4 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -125,7 +125,7 @@ impl Default for RotateDraft { } impl PdfCraftApp { - /// Ask for files to combine (File ▸ Combine files…). + /// Ask for files to add to the Combine files list (its Add files… button). pub fn combine_dialog(&mut self) { self.pick_files(FilePurpose::Combine, true); } @@ -178,6 +178,7 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn use_paths(&mut self, purpose: FilePurpose, paths: &[std::path::PathBuf]) { let mut files = Vec::new(); + let mut modified = Vec::new(); for p in paths { let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "file.pdf".into()); match std::fs::read(p) { @@ -187,12 +188,60 @@ impl PdfCraftApp { return; } } + modified.push(std::fs::metadata(p).and_then(|m| m.modified()).ok()); } - if !files.is_empty() { + if files.is_empty() { + return; + } + // Combine files lists when each file was last modified. + if purpose == FilePurpose::Combine { + let incoming = files + .into_iter() + .zip(modified) + .map(|((name, bytes), modified)| crate::combine_ui::Incoming { name, bytes: Arc::new(bytes), modified, note: None }) + .collect(); + self.stage_combine_with(incoming); + } else { self.use_files(purpose, files); } } + /// A file dropped on the Combine files tab: added to its list. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn drop_into_combine(&mut self, f: egui::DroppedFileHandle, _ctx: &egui::Context) { + // A folder: the PDFs in it and in the folders inside it. + if f.path().is_absolute() && f.path().is_dir() { + self.add_folder_to_combine(f.path(), true); + return; + } + if f.path().is_absolute() { + self.use_paths(FilePurpose::Combine, &[f.path().to_path_buf()]); + return; + } + let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into()); + match f.bytes() { + Ok(bytes) => self.use_files(FilePurpose::Combine, vec![(name, bytes)]), + Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]), + } + } + + /// Browsers read dropped files asynchronously; they join the Combine files list next frame. + #[cfg(target_arch = "wasm32")] + pub(crate) fn drop_into_combine(&mut self, f: egui::DroppedFileHandle, ctx: &egui::Context) { + let requests = self.requests.clone(); + let request = self.file_request(FilePurpose::Combine, Vec::new()); + let ctx = ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into()); + if let Ok(bytes) = f.bytes_async().await + && let Ok(mut q) = requests.lock() + { + q.push(FileRequest { files: vec![(name, bytes)], ..request }); + ctx.request_repaint(); + } + }); + } + /// A request to use `files` for `purpose`, bound to the document it will edit: the active /// one now, for Insert and Replace. Browsers make it when the picker opens and queue it on /// `requests` once the files are read; it is used on a later frame. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index dce2b18a..38ee8949 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1522,7 +1522,63 @@ Sent to {printer} Enviado a {printer} Sent to {printer}: {msg} Enviado a {printer}: {msg} Choose a folder for the searchable files Elige una carpeta para los archivos con búsqueda - Files are combined in this order. Leave Pages empty to take every page. Los archivos se combinan en este orden. Deja Páginas vacío para incluir todas las páginas. + Files are combined from top to bottom. Leave Pages empty to take every page. Los archivos se combinan de arriba abajo. Deja Páginas vacío para incluir todas las páginas. + Unlock… Desbloquear… + Select a password-protected file first Selecciona primero un archivo protegido con contraseña + Enter the permissions password to allow combining Introduce la contraseña de permisos para permitir combinarlo + Enter the password that opens it Introduce la contraseña que lo abre + Unlock {name} Desbloquear {name} + Unlock {n} files Desbloquear {n} archivos + The same password is tried on each file. Se prueba la misma contraseña en cada archivo. + Hide password Ocultar contraseña + Show password Mostrar contraseña + Wrong password Contraseña incorrecta + The password is only used to read the file; it isn't saved. La contraseña solo se usa para leer el archivo; no se guarda. + Unlock Desbloquear + Opened, but its security settings don't allow combining. Enter the permissions password. Se abrió, pero su configuración de seguridad no permite combinarlo. Introduce la contraseña de permisos. + Unlocked: the combined file won't be password-protected Desbloqueado: el archivo combinado no estará protegido con contraseña + Unlocked {n} of {m} files Se desbloquearon {n} de {m} archivos + Drop PDFs or folders here, or choose them. Suelta aquí PDF o carpetas, o elígelos. + Add folder… Añadir carpeta… + Add folder and subfolders… Añadir carpeta y subcarpetas… + More ways to add files Más formas de añadir archivos + Every PDF in the folder Todos los PDF de la carpeta + Every PDF in the folder and the folders inside it Todos los PDF de la carpeta y de las carpetas que contiene + Add folder Añadir carpeta + Add folder and subfolders Añadir carpeta y subcarpetas + No PDFs found in {folder} No se encontraron PDF en {folder} + Added the first {n} PDFs in {folder} Se añadieron los primeros {n} PDF de {folder} + Click to sort, drag to move the column. Drag its edge to resize it. Haz clic para ordenar; arrastra para mover la columna. Arrastra su borde para cambiar el ancho. + Reset columns Restablecer columnas + Remove {n} files Quitar {n} archivos + {n} selected {n} seleccionados + sorted ascending orden ascendente + sorted descending orden descendente + Protected: the combined file won't keep its security settings Protegido: el archivo combinado no conservará su configuración de seguridad + Add open documents Añadir documentos abiertos + All open documents Todos los documentos abiertos + No documents are open No hay documentos abiertos + Select a file first Selecciona primero un archivo + 1 file 1 archivo + {n} files {n} archivos + 1 file needs attention 1 archivo necesita atención + {n} files need attention {n} archivos necesitan atención + {name} can't be combined: {why} No se puede combinar {name}: {why} + Check the pages of {name}: {why} Revisa las páginas de {name}: {why} + Add the PDFs to combine Añade los PDF que quieres combinar + File name Nombre de archivo + Warnings Advertencias + Drag to reorder Arrastra para reordenar + {k} of {n} {k} de {n} + ? of {n} ? de {n} + Added more than once Añadido más de una vez + yesterday ayer + {n} days ago hace {n} días + No pages selected No hay páginas seleccionadas + Password-protected Protegido con contraseña + Its security settings don't allow copying pages Su configuración de seguridad no permite copiar páginas + Can't be read: {e} No se puede leer: {e} + Includes unsaved changes Incluye cambios sin guardar Pages of {name} to combine, e.g. 1-3, 6 Páginas de {name} que combinar, p. ej., 1-3, 6 Remove {name} Quitar {name} Add files… Añadir archivos… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f3c2c18f..d5de2b08 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1931,3 +1931,59 @@ Lines +/− Lignes +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{bin_added} / −{bin_deleted} fichiers binaires, {first} – {last} Set layer visibility Définir la visibilité des calques + +# Combine files tab + 1 file 1 fichier + 1 file needs attention 1 fichier demande votre attention + ? of {n} ? sur {n} + Add folder Ajouter un dossier + Add folder and subfolders Ajouter un dossier et ses sous-dossiers + Add folder and subfolders… Ajouter un dossier et ses sous-dossiers… + Add folder… Ajouter un dossier… + Add open documents Ajouter des documents ouverts + Add the PDFs to combine Ajoutez les PDF à combiner + Added more than once Ajouté plusieurs fois + All open documents Tous les documents ouverts + Can't be read: {e} Illisible : {e} + Check the pages of {name}: {why} Vérifiez les pages de {name} : {why} + Click to sort, drag to move the column. Drag its edge to resize it. Cliquer pour trier, faire glisser pour déplacer la colonne. Faire glisser son bord pour la redimensionner. + Drag to reorder Faire glisser pour réordonner + Drop PDFs or folders here, or choose them. Déposez ici des PDF ou des dossiers, ou choisissez-les. + Enter the password that opens it Saisissez le mot de passe qui l'ouvre + Enter the permissions password to allow combining Saisissez le mot de passe des autorisations pour permettre de le combiner + Every PDF in the folder Tous les PDF du dossier + Every PDF in the folder and the folders inside it Tous les PDF du dossier et des dossiers qu'il contient + File name Nom du fichier + Files are combined from top to bottom. Leave Pages empty to take every page. Les fichiers sont combinés de haut en bas. Laissez Pages vide pour prendre toutes les pages. + Hide password Masquer le mot de passe + Includes unsaved changes Inclut des modifications non enregistrées + Its security settings don't allow copying pages Ses paramètres de sécurité n'autorisent pas la copie des pages + More ways to add files Autres façons d'ajouter des fichiers + No documents are open Aucun document n'est ouvert + No pages selected Aucune page sélectionnée + Opened, but its security settings don't allow combining. Enter the permissions password. Ouvert, mais ses paramètres de sécurité n'autorisent pas la combinaison. Saisissez le mot de passe des autorisations. + Password-protected Protégé par mot de passe + Protected: the combined file won't keep its security settings Protégé : le fichier combiné ne conservera pas ses paramètres de sécurité + Remove {n} files Retirer {n} fichiers + Reset columns Réinitialiser les colonnes + Select a file first Sélectionnez d'abord un fichier + Select a password-protected file first Sélectionnez d'abord un fichier protégé par mot de passe + Show password Afficher le mot de passe + The password is only used to read the file; it isn't saved. Le mot de passe sert uniquement à lire le fichier ; il n'est pas enregistré. + The same password is tried on each file. Le même mot de passe est essayé sur chaque fichier. + Unlock Déverrouiller + Unlock {name} Déverrouiller {name} + Unlock {n} files Déverrouiller {n} fichiers + Unlocked: the combined file won't be password-protected Déverrouillé : le fichier combiné ne sera pas protégé par mot de passe + Unlock… Déverrouiller… + Warnings Avertissements + Wrong password Mot de passe incorrect + sorted ascending tri croissant + sorted descending tri décroissant + yesterday hier + {k} of {n} {k} sur {n} + {name} can't be combined: {why} Impossible de combiner {name} : {why} + {n} days ago il y a {n} jours + {n} files {n} fichiers + {n} files need attention {n} fichiers demandent votre attention + {n} selected {n} sélectionnés diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a829bd68..3ecfdc00 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1528,7 +1528,63 @@ Sent to {printer} {printer} に送信しました Sent to {printer}: {msg} {printer} に送信しました: {msg} Choose a folder for the searchable files 検索可能なファイルの保存先フォルダーを選択 - Files are combined in this order. Leave Pages empty to take every page. この順序でファイルを結合します。すべてのページを使用する場合は「ページ」を空欄にしてください。 + Files are combined from top to bottom. Leave Pages empty to take every page. ファイルは上から順に結合されます。すべてのページを使用する場合は「ページ」を空欄にしてください。 + Unlock… ロックを解除… + Select a password-protected file first 先にパスワードで保護されたファイルを選択してください + Enter the permissions password to allow combining 結合を許可するには権限パスワードを入力してください + Enter the password that opens it ファイルを開くパスワードを入力してください + Unlock {name} {name} のロックを解除 + Unlock {n} files {n} 個のファイルのロックを解除 + The same password is tried on each file. 同じパスワードを各ファイルに試します。 + Hide password パスワードを隠す + Show password パスワードを表示 + Wrong password パスワードが違います + The password is only used to read the file; it isn't saved. パスワードはファイルの読み込みにのみ使用され、保存されません。 + Unlock ロックを解除 + Opened, but its security settings don't allow combining. Enter the permissions password. 開けましたが、セキュリティ設定により結合が許可されていません。権限パスワードを入力してください。 + Unlocked: the combined file won't be password-protected ロック解除済み: 結合後のファイルはパスワードで保護されません + Unlocked {n} of {m} files {m} 個中 {n} 個のファイルのロックを解除しました + Drop PDFs or folders here, or choose them. ここに PDF またはフォルダーをドロップするか、選択してください。 + Add folder… フォルダーを追加… + Add folder and subfolders… フォルダーとサブフォルダーを追加… + More ways to add files ファイルを追加するその他の方法 + Every PDF in the folder フォルダー内のすべての PDF + Every PDF in the folder and the folders inside it フォルダーとその中のフォルダーにあるすべての PDF + Add folder フォルダーを追加 + Add folder and subfolders フォルダーとサブフォルダーを追加 + No PDFs found in {folder} {folder} に PDF が見つかりません + Added the first {n} PDFs in {folder} {folder} の最初の {n} 個の PDF を追加しました + Click to sort, drag to move the column. Drag its edge to resize it. クリックで並べ替え、ドラッグで列を移動します。列の端をドラッグすると幅を変更できます。 + Reset columns 列をリセット + Remove {n} files {n} 個のファイルを削除 + {n} selected {n} 個選択 + sorted ascending 昇順 + sorted descending 降順 + Protected: the combined file won't keep its security settings 保護されています: 結合後のファイルにはセキュリティ設定が引き継がれません + Add open documents 開いている文書を追加 + All open documents 開いているすべての文書 + No documents are open 開いている文書はありません + Select a file first 先にファイルを選択してください + 1 file 1 個のファイル + {n} files {n} 個のファイル + 1 file needs attention 1 個のファイルに問題があります + {n} files need attention {n} 個のファイルに問題があります + {name} can't be combined: {why} {name} は結合できません: {why} + Check the pages of {name}: {why} {name} のページ指定を確認してください: {why} + Add the PDFs to combine 結合する PDF を追加 + File name ファイル名 + Warnings 警告 + Drag to reorder ドラッグして並べ替え + {k} of {n} {k}/{n} + ? of {n} ?/{n} + Added more than once 複数回追加されています + yesterday 昨日 + {n} days ago {n} 日前 + No pages selected ページが選択されていません + Password-protected パスワードで保護されています + Its security settings don't allow copying pages セキュリティ設定によりページのコピーが許可されていません + Can't be read: {e} 読み込めません: {e} + Includes unsaved changes 未保存の変更を含みます Pages of {name} to combine, e.g. 1-3, 6 結合する {name} のページ(例: 1-3, 6) Remove {name} {name} を削除 Add files… ファイルを追加… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2eb43aaa..34fa5b9f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1479,7 +1479,63 @@ Sent to {printer} 已发送到{printer} Sent to {printer}: {msg} 已发送到{printer}:{msg} Choose a folder for the searchable files 选择可搜索文件的文件夹 - Files are combined in this order. Leave Pages empty to take every page. 文件将按此顺序合并。页码留空表示取全部页面。 + Files are combined from top to bottom. Leave Pages empty to take every page. 文件按从上到下的顺序合并。页码留空表示取全部页面。 + Unlock… 解锁… + Select a password-protected file first 请先选择一个受密码保护的文件 + Enter the permissions password to allow combining 输入权限密码以允许合并 + Enter the password that opens it 输入打开该文件的密码 + Unlock {name} 解锁 {name} + Unlock {n} files 解锁 {n} 个文件 + The same password is tried on each file. 将对每个文件尝试同一密码。 + Hide password 隐藏密码 + Show password 显示密码 + Wrong password 密码错误 + The password is only used to read the file; it isn't saved. 密码仅用于读取文件,不会被保存。 + Unlock 解锁 + Opened, but its security settings don't allow combining. Enter the permissions password. 已打开,但其安全设置不允许合并。请输入权限密码。 + Unlocked: the combined file won't be password-protected 已解锁:合并后的文件将不受密码保护 + Unlocked {n} of {m} files 已解锁 {m} 个文件中的 {n} 个 + Drop PDFs or folders here, or choose them. 将 PDF 或文件夹拖放到此处,或选择它们。 + Add folder… 添加文件夹… + Add folder and subfolders… 添加文件夹及子文件夹… + More ways to add files 更多添加文件的方式 + Every PDF in the folder 文件夹中的所有 PDF + Every PDF in the folder and the folders inside it 文件夹及其子文件夹中的所有 PDF + Add folder 添加文件夹 + Add folder and subfolders 添加文件夹及子文件夹 + No PDFs found in {folder} 在 {folder} 中未找到 PDF + Added the first {n} PDFs in {folder} 已添加 {folder} 中的前 {n} 个 PDF + Click to sort, drag to move the column. Drag its edge to resize it. 单击以排序,拖动以移动列。拖动列边缘可调整宽度。 + Reset columns 重置列 + Remove {n} files 删除 {n} 个文件 + {n} selected 已选择 {n} 个 + sorted ascending 升序 + sorted descending 降序 + Protected: the combined file won't keep its security settings 受保护:合并后的文件不会保留其安全设置 + Add open documents 添加已打开的文档 + All open documents 所有已打开的文档 + No documents are open 没有打开的文档 + Select a file first 请先选择一个文件 + 1 file 1 个文件 + {n} files {n} 个文件 + 1 file needs attention 1 个文件需要处理 + {n} files need attention {n} 个文件需要处理 + {name} can't be combined: {why} 无法合并 {name}:{why} + Check the pages of {name}: {why} 请检查 {name} 的页码:{why} + Add the PDFs to combine 添加要合并的 PDF + File name 文件名 + Warnings 警告 + Drag to reorder 拖动以重新排序 + {k} of {n} {k}/{n} + ? of {n} ?/{n} + Added more than once 重复添加 + yesterday 昨天 + {n} days ago {n} 天前 + No pages selected 未选择任何页面 + Password-protected 受密码保护 + Its security settings don't allow copying pages 其安全设置不允许复制页面 + Can't be read: {e} 无法读取:{e} + Includes unsaved changes 包含未保存的更改 Pages of {name} to combine, e.g. 1-3, 6 要合并的{name}的页面,例如 1-3、6 Remove {name} 删除{name} Add files… 添加文件… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8c421725..3729710d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1437,7 +1437,63 @@ Sent to {printer} 已傳送到{printer} Sent to {printer}: {msg} 已傳送到{printer}:{msg} Choose a folder for the searchable files 選擇可搜尋檔案的資料夾 - Files are combined in this order. Leave Pages empty to take every page. 檔案將按此順序合併。頁碼留空表示取全部頁面。 + Files are combined from top to bottom. Leave Pages empty to take every page. 檔案將按從上到下的順序合併。頁碼留空表示取全部頁面。 + Unlock… 解除鎖定… + Select a password-protected file first 請先選取一個受密碼保護的檔案 + Enter the permissions password to allow combining 輸入權限密碼以允許合併 + Enter the password that opens it 輸入開啟該檔案的密碼 + Unlock {name} 解除鎖定 {name} + Unlock {n} files 解除鎖定 {n} 個檔案 + The same password is tried on each file. 將對每個檔案嘗試同一密碼。 + Hide password 隱藏密碼 + Show password 顯示密碼 + Wrong password 密碼錯誤 + The password is only used to read the file; it isn't saved. 密碼僅用於讀取檔案,不會被儲存。 + Unlock 解除鎖定 + Opened, but its security settings don't allow combining. Enter the permissions password. 已開啟,但其安全性設定不允許合併。請輸入權限密碼。 + Unlocked: the combined file won't be password-protected 已解除鎖定:合併後的檔案將不受密碼保護 + Unlocked {n} of {m} files 已解除鎖定 {m} 個檔案中的 {n} 個 + Drop PDFs or folders here, or choose them. 將 PDF 或資料夾拖放到此處,或選取它們。 + Add folder… 新增資料夾… + Add folder and subfolders… 新增資料夾及子資料夾… + More ways to add files 更多新增檔案的方式 + Every PDF in the folder 資料夾中的所有 PDF + Every PDF in the folder and the folders inside it 資料夾及其子資料夾中的所有 PDF + Add folder 新增資料夾 + Add folder and subfolders 新增資料夾及子資料夾 + No PDFs found in {folder} 在 {folder} 中找不到 PDF + Added the first {n} PDFs in {folder} 已新增 {folder} 中的前 {n} 個 PDF + Click to sort, drag to move the column. Drag its edge to resize it. 按一下以排序,拖曳以移動欄。拖曳欄邊緣可調整寬度。 + Reset columns 重設欄 + Remove {n} files 刪除 {n} 個檔案 + {n} selected 已選取 {n} 個 + sorted ascending 遞增排序 + sorted descending 遞減排序 + Protected: the combined file won't keep its security settings 受保護:合併後的檔案不會保留其安全性設定 + Add open documents 新增已開啟的文件 + All open documents 所有已開啟的文件 + No documents are open 沒有開啟的文件 + Select a file first 請先選取一個檔案 + 1 file 1 個檔案 + {n} files {n} 個檔案 + 1 file needs attention 1 個檔案需要處理 + {n} files need attention {n} 個檔案需要處理 + {name} can't be combined: {why} 無法合併 {name}:{why} + Check the pages of {name}: {why} 請檢查 {name} 的頁碼:{why} + Add the PDFs to combine 新增要合併的 PDF + File name 檔案名稱 + Warnings 警告 + Drag to reorder 拖曳以重新排序 + {k} of {n} {k}/{n} + ? of {n} ?/{n} + Added more than once 重複新增 + yesterday 昨天 + {n} days ago {n} 天前 + No pages selected 未選取任何頁面 + Password-protected 受密碼保護 + Its security settings don't allow copying pages 其安全性設定不允許複製頁面 + Can't be read: {e} 無法讀取:{e} + Includes unsaved changes 包含未儲存的變更 Pages of {name} to combine, e.g. 1-3, 6 要合併的{name}的頁面,例如 1-3、6 Remove {name} 刪除{name} Add files… 新增檔案… diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 84daa9e4..a8f0fc20 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,7 @@ mod actions_ui; pub mod canvas; mod chrome; mod combine_ui; +pub use combine_ui::{Columns as CombineColumns, Lock as CombineLock, SortKey}; mod commands; mod comment_props; pub mod comments; @@ -245,8 +246,6 @@ pub enum Dialog { ActionWizard, /// Standards ▸ PDF/A. PdfA, - /// Combine files: the files, their order and pages. - Combine, /// Custom stamps ▸ Create. CreateStamp, /// Prepare for accessibility ▸ Add alternate text. @@ -427,6 +426,10 @@ pub struct PdfCraftApp { pub space_audit: Vec, /// Combine files: the files staged so far. pub combine_draft: Vec, + /// The Combine files tab: whether it is open, shown, its selection and undo history. + pub combine_tab: combine_ui::CombineTab, + /// The Combine files table's column order and widths (kept in the settings). + pub combine_columns: combine_ui::Columns, /// Images waiting for the resolution choice (released on cancel). pub image_import: Option, /// The custom stamp library, and the stamp being created. @@ -626,6 +629,8 @@ impl PdfCraftApp { cert_viewer: None, space_audit: Vec::new(), combine_draft: Vec::new(), + combine_tab: Default::default(), + combine_columns: Default::default(), image_import: None, custom_stamps: Vec::new(), stamp_draft: Default::default(), @@ -1071,6 +1076,7 @@ impl PdfCraftApp { "custom_stamps": stamps_ui::encode(&self.custom_stamps), "javascript": self.session.javascript(), "actions": actions_ui::encode(&self.custom_actions), + "combine_columns": self.combine_columns.to_json(), }) .to_string() } @@ -1078,6 +1084,7 @@ impl PdfCraftApp { /// Restore state written by `persist`. Unknown or malformed data is ignored. pub fn restore(&mut self, json: &str) { let Ok(v) = serde_json::from_str::(json) else { return }; + self.combine_columns = combine_ui::Columns::from_json(&v["combine_columns"]); if let Ok(r) = serde_json::from_value::>(v["recent"].clone()) { // Only keep entries whose files still exist. #[cfg(not(target_arch = "wasm32"))] @@ -1447,9 +1454,18 @@ impl eframe::App for PdfCraftApp { } } self.sync_theme(ctx); + // Showing a document hides the Combine files tab. + if self.active.is_some() { + self.combine_tab.focused = false; + } let dropped = ctx.input(|i| i.raw.dropped_files.clone()); for f in dropped { - self.open_dropped(f, ctx); + // Files dropped on the Combine files tab join its list instead of opening. + if self.combine_showing() { + self.drop_into_combine(f, ctx); + } else { + self.open_dropped(f, ctx); + } } let arrived: Vec<(String, Vec)> = self.inbox.lock().map(|mut q| std::mem::take(&mut *q)).unwrap_or_default(); for (name, bytes) in arrived { @@ -1527,7 +1543,9 @@ impl eframe::App for PdfCraftApp { let title = self .active .and_then(|i| self.session.get(self.views[i].id)) - .map_or_else(|| "PdfCraft".to_owned(), |d| format!("{} — PdfCraft", d.display_name())); + .map(|d| d.display_name()) + .or_else(|| self.combine_showing().then(|| tl!("Combine files").to_owned())) + .map_or_else(|| "PdfCraft".to_owned(), |name| format!("{name} — PdfCraft")); if title != self.window_title { ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); self.window_title = title; @@ -1561,6 +1579,7 @@ impl eframe::App for PdfCraftApp { } let t = theme::Tokens::get(&ctx); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| match self.active { + None if self.combine_showing() => combine_ui::page(self, ui), None => home::show(self, ui), Some(i) => canvas::document_area(self, i, ui), }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index bbc5784f..9f5d41f2 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -144,6 +144,34 @@ pub fn community_links(ui: &mut egui::Ui) -> Option<&'static str> { clicked } +/// [`icon_pill`] with a ▾ part at its end that opens a menu of related choices. Returns the +/// main button's response and the ▾'s (give it to `egui::Popup::menu`). +pub fn split_pill(ui: &mut egui::Ui, icon: &str, label: &str, more: &str) -> (Response, Response) { + let t = Tokens::get(ui.ctx()); + let font = theme::medium(12.5); + let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); + let (rect, _) = ui.allocate_exact_size(vec2(w + 46.0 + 28.0, 30.0), Sense::hover()); + let (main_rect, more_rect) = rect.split_left_right_at_x(rect.right() - 28.0); + let main = ui.interact(main_rect, ui.id().with(("split-pill", label)), Sense::click()); + let arrow = ui.interact(more_rect, ui.id().with(("split-pill-more", label)), Sense::click()); + main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), more)); + let radius = CornerRadius::same(15); + ui.painter().rect(rect, radius, t.card, Stroke::new(1.2, t.text_muted), egui::StrokeKind::Inside); + let left = CornerRadius { nw: 15, sw: 15, ne: 0, se: 0 }; + let right = CornerRadius { nw: 0, sw: 0, ne: 15, se: 15 }; + for (r, resp, corners) in [(main_rect, &main, left), (more_rect, &arrow, right)] { + if resp.hovered() { + ui.painter().rect_filled(r.shrink(1.2), corners, t.hover); + } + } + ui.painter().vline(more_rect.left(), rect.y_range().shrink(7.0), Stroke::new(1.0, t.text_muted)); + crate::icons::paint(ui, Rect::from_min_size(rect.min + vec2(12.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, t.text); + ui.painter().text(rect.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, label, font, t.text); + crate::icons::paint(ui, Rect::from_center_size(more_rect.center(), vec2(14.0, 14.0)), "chevron-down", 13.0, t.text); + (main.on_hover_text(label), arrow.on_hover_text(more)) +} + /// A pill button with an icon (primary = filled accent). pub fn icon_pill(ui: &mut egui::Ui, icon: &str, label: &str, primary: bool) -> Response { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 510fda00..21baf309 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -4,7 +4,7 @@ use egui::accesskit::Role; use egui::{Key, Modifiers}; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{NodeT, Queryable}; use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; use pdfcraft_ui_egui::{CloseRequest, PdfCraftApp}; @@ -507,13 +507,20 @@ fn combine_files_takes_chosen_pages_in_the_order_listed() { app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("one.pdf".into(), fixture(3)), ("two.pdf".into(), fixture(2))]); }); h.run_steps(3); - h.get_by_label_contains("Files are combined in this order"); - h.get_by_label("3 pages"); - // two.pdf first; one.pdf's pages 3 and 1. - h.get_all_by_label("Move up").last().unwrap().click(); + h.get_by_label_contains("Files are combined from top to bottom"); + // Nothing is selected yet: the toolbar's Move up is there but disabled. + assert!(h.get_by_label("Move up").accesskit_node().is_disabled()); + // Select two.pdf with the keyboard and move it up: two.pdf first; one.pdf's pages 3 and 1. + h.key_press(Key::ArrowUp); h.run_steps(2); + assert_eq!(h.state().combine_selection(), [1]); + h.get_by_label("Move up").click(); + h.run_steps(2); + assert_eq!(h.state().combine_draft[0].name, "two.pdf"); + assert_eq!(h.state().combine_selection(), [0], "the selection follows the moved file"); h.state_mut().combine_draft[1].range = "3, 1".into(); - h.run_steps(1); + h.run_steps(2); + h.get_by_label("2 of 3"); h.get_by_label("Combine").click(); h.run_steps(3); let app = h.state(); @@ -521,6 +528,405 @@ fn combine_files_takes_chosen_pages_in_the_order_listed() { let doc = app.session.get(app.views[1].id).unwrap(); assert_eq!(doc.info.outline.iter().map(|o| o.title.as_str()).collect::>(), ["two", "one"]); assert!(app.combine_draft.is_empty()); + assert!(!app.combine_tab.open, "the tab makes way for the result"); +} + +#[test] +fn combine_files_opens_in_its_own_tab() { + let mut h = harness(1, |_| {}); + h.state_mut().execute("page.combine"); + h.run_steps(3); + assert!(h.state().combine_showing() && h.state().active.is_none()); + h.get_by_label("Add the PDFs to combine"); + // Back to the document, then to Combine through its tab. + h.get_by_label("doc.pdf").click(); + h.run_steps(3); + assert_eq!(h.state().active, Some(0)); + assert!(!h.state().combine_showing() && h.state().combine_tab.open); + // The tab, in the tab strip (All tools lists Combine files too). + h.get_all_by_label("Combine files").find(|n| n.rect().top() < 40.0).unwrap().click(); + h.run_steps(3); + assert!(h.state().combine_showing()); + // Home hides it too. + h.get_by_label("Home").click(); + h.run_steps(3); + assert!(!h.state().combine_showing() && h.state().combine_tab.open); +} + +#[test] +fn closing_the_combine_tab_forgets_its_list() { + let mut h = harness(1, |app| { + app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("one.pdf".into(), fixture(1))]); + }); + h.run_steps(3); + assert!(h.state().combine_showing()); + h.state_mut().close_combine_tab(); + h.run_steps(3); + let app = h.state(); + assert!(app.combine_draft.is_empty() && !app.combine_tab.open); + assert_eq!(app.active, Some(0), "the document next to it shows"); +} + +#[test] +fn combine_lists_size_and_warns_before_combining() { + let mut h = harness(1, |app| { + app.use_files( + pdfcraft_ui_egui::FilePurpose::Combine, + vec![ + ("one.pdf".into(), fixture(3)), + ("locked.pdf".into(), protected("", "owner", -1 ^ 1024 ^ 8)), + ("secret.pdf".into(), protected("pw", "owner", -1)), + ], + ); + }); + h.run_steps(3); + h.get_by_label("File name"); + h.get_by_label("Warnings"); + h.get_by_label("Its security settings don't allow copying pages"); + h.get_by_label("Password-protected"); + h.get_by_label("2 files need attention"); + assert!(h.get_by_label("Combine").accesskit_node().is_disabled()); + // Removing them (Delete on the selection) lets Combine run. + for _ in 0..2 { + h.state_mut().select_combine_rows(&[1]); + h.key_press(Key::Delete); + h.run_steps(2); + } + assert_eq!(h.state().combine_draft.len(), 1); + h.get_by_label_contains("1 file · 3 pages"); +} + +#[test] +fn a_bad_range_stops_combine_until_fixed() { + let mut h = harness(1, |app| { + app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("one.pdf".into(), fixture(3)), ("two.pdf".into(), fixture(2))]); + }); + h.run_steps(3); + h.get_by_label_contains("2 files · 5 pages"); + h.state_mut().combine_draft[0].range = "1-99".into(); + h.run_steps(2); + h.get_by_label("1 file needs attention"); + assert!(h.get_by_label("Combine").accesskit_node().is_disabled()); + h.state_mut().combine_draft[0].range = "2-3".into(); + h.run_steps(2); + h.get_by_label_contains("2 files · 4 pages"); + assert!(!h.get_by_label("Combine").accesskit_node().is_disabled()); +} + +#[test] +fn alt_arrows_move_the_selected_file() { + let mut h = harness(1, |app| { + app.use_files( + pdfcraft_ui_egui::FilePurpose::Combine, + vec![("a.pdf".into(), fixture(1)), ("b.pdf".into(), fixture(1)), ("c.pdf".into(), fixture(1))], + ); + }); + h.run_steps(3); + h.key_press(Key::ArrowDown); + h.run_steps(1); + h.key_press_modifiers(Modifiers::ALT, Key::ArrowDown); + h.run_steps(2); + let names: Vec<_> = h.state().combine_draft.iter().map(|f| f.name.clone()).collect(); + assert_eq!(names, ["b.pdf", "a.pdf", "c.pdf"]); + assert_eq!(h.state().combine_selection(), [1]); +} + +fn combine_names(h: &Harness<'static, PdfCraftApp>) -> Vec { + h.state().combine_draft.iter().map(|f| f.name.clone()).collect() +} + +fn combine_of(names: &[(&str, usize)]) -> Harness<'static, PdfCraftApp> { + let files: Vec<(String, Vec)> = names.iter().map(|(n, p)| (n.to_string(), fixture(*p))).collect(); + let mut h = harness(1, move |app| app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, files)); + h.run_steps(3); + h +} + +#[test] +fn column_headings_sort_the_list_and_flip_on_the_second_click() { + let mut h = combine_of(&[("scan10.pdf", 1), ("Scan2.pdf", 3), ("a.pdf", 2)]); + h.get_by_label("File name").click(); + h.run_steps(2); + assert_eq!(combine_names(&h), ["a.pdf", "Scan2.pdf", "scan10.pdf"], "case-insensitive, numbers by value"); + h.get_by_label("File name, sorted ascending").click(); + h.run_steps(2); + assert_eq!(combine_names(&h), ["scan10.pdf", "Scan2.pdf", "a.pdf"]); + h.get_by_label("Pages").click(); + h.run_steps(2); + assert_eq!(combine_names(&h), ["scan10.pdf", "a.pdf", "Scan2.pdf"]); + // Moving a file by hand ends the sorted order. + h.state_mut().select_combine_rows(&[2]); + h.get_by_label("Move up").click(); + h.run_steps(2); + assert_eq!(h.state().combine_tab.sort, None); + h.get_by_label("Pages"); +} + +#[test] +fn ctrl_and_shift_clicks_select_several_files_that_move_and_go_together() { + let mut h = combine_of(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1), ("d.pdf", 1), ("e.pdf", 1)]); + h.get_by_label("b.pdf").click(); + h.run_steps(1); + h.get_by_label("d.pdf").click_modifiers(Modifiers::SHIFT); + h.run_steps(1); + assert_eq!(h.state().combine_selection(), [1, 2, 3]); + h.get_by_label("c.pdf").click_modifiers(Modifiers::COMMAND); + h.run_steps(1); + assert_eq!(h.state().combine_selection(), [1, 3], "Ctrl/⌘ toggles one file"); + h.get_by_label("2 selected"); + h.get_by_label("Move up").click(); + h.run_steps(2); + assert_eq!(combine_names(&h), ["b.pdf", "a.pdf", "d.pdf", "c.pdf", "e.pdf"]); + h.get_by_label("Remove 2 files").click(); + h.run_steps(2); + assert_eq!(combine_names(&h), ["a.pdf", "c.pdf", "e.pdf"]); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + assert_eq!(h.state().combine_selection(), [0, 1, 2]); +} + +#[test] +fn undo_and_redo_restore_the_combine_list() { + let mut h = combine_of(&[("a.pdf", 1), ("b.pdf", 1), ("c.pdf", 1)]); + h.state_mut().select_combine_rows(&[0, 2]); + h.key_press(Key::Delete); + h.run_steps(2); + assert_eq!(combine_names(&h), ["b.pdf"]); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(2); + assert_eq!(combine_names(&h), ["a.pdf", "b.pdf", "c.pdf"], "⌘Z brings the removed files back"); + assert_eq!(h.state().combine_selection(), [0, 2], "…selected as they were"); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); + h.run_steps(2); + assert_eq!(combine_names(&h), ["b.pdf"]); + // The toolbar's buttons do the same; undo also reverses a sort and the files' addition. + h.get_by_label("Undo").click(); + h.run_steps(2); + h.get_by_label("File name").click(); + h.run_steps(2); + h.get_by_label("File name, sorted ascending").click(); + h.run_steps(2); + assert_eq!(combine_names(&h), ["c.pdf", "b.pdf", "a.pdf"]); + h.get_by_label("Undo").click(); + h.run_steps(2); + assert_eq!(combine_names(&h), ["a.pdf", "b.pdf", "c.pdf"]); + // A page range typed in is one step. + h.state_mut().combine_draft[0].range.clear(); + let field = h.get_all_by_role(Role::TextInput).next().unwrap(); + field.focus(); + h.run_steps(1); + h.get_all_by_role(Role::TextInput).next().unwrap().type_text("1"); + h.run_steps(2); + assert_eq!(h.state().combine_draft[0].range, "1"); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(2); + assert_eq!(h.state().combine_draft[0].range, ""); + // The document's own history is untouched: it has nothing to undo. + h.get_by_label("doc.pdf").click(); + h.run_steps(2); + assert!(h.state().session.get(h.state().views[0].id).unwrap().can_undo().is_none()); +} + +#[test] +fn protected_files_that_can_be_combined_are_flagged() { + let mut h = harness(1, |app| { + app.use_files( + pdfcraft_ui_egui::FilePurpose::Combine, + vec![("one.pdf".into(), fixture(1)), ("no-copy.pdf".into(), protected("", "owner", -1 ^ 16))], + ); + }); + h.run_steps(3); + let app = h.state(); + assert!(app.combine_draft[1].problem.is_none(), "copying text is withheld, assembling pages isn't"); + h.get_by_label("Protected: the combined file won't keep its security settings"); + assert!(!h.get_by_label("Combine").accesskit_node().is_disabled(), "a warning, not a blocker"); +} + +#[test] +fn the_whole_heading_sorts_and_dragging_it_moves_the_column() { + let mut h = combine_of(&[("b.pdf", 1), ("a.pdf", 3)]); + // A click in the empty right part of the heading cell, away from its text. + let cell = h.get_by_label("File name").rect(); + let spot = egui::pos2(cell.right() - 30.0, cell.center().y); + h.hover_at(spot); + h.run_steps(1); + h.drag_at(spot); + h.run_steps(1); + h.drop_at(spot); + h.run_steps(2); + assert_eq!(combine_names(&h), ["a.pdf", "b.pdf"]); + // Drag Size before File name. + let size = h.get_by_label("Size").rect().center(); + let name = h.get_by_label("File name, sorted ascending").rect(); + drag(&mut h, size, egui::pos2(name.left() + 10.0, name.center().y)); + use pdfcraft_ui_egui::SortKey::*; + assert_eq!(h.state().combine_columns.order, [Size, Name, Pages, Modified, Warnings]); + assert_eq!(combine_names(&h), ["a.pdf", "b.pdf"], "moving a column doesn't sort"); + let (size, name) = (h.get_by_label("Size").rect(), h.get_by_label("File name, sorted ascending").rect()); + assert!(size.left() < name.left(), "Size now shows first"); +} + +#[test] +fn columns_resize_and_the_layout_is_kept_in_the_settings() { + let mut h = combine_of(&[("a.pdf", 1), ("b.pdf", 1)]); + let size = h.get_by_label("Size").rect(); + // The separator after Size: drag it right by 60 points. + let edge = egui::pos2(size.right() + 2.0, size.center().y); + drag(&mut h, edge, edge + egui::vec2(60.0, 0.0)); + let widths = h.state().combine_columns.widths.expect("a resized column keeps its width"); + let size_index = pdfcraft_ui_egui::SortKey::Size as usize; + assert!(widths[size_index] > 120.0, "{widths:?}"); + let saved = h.state().persist(); + let mut fresh = PdfCraftApp::new(); + fresh.restore(&saved); + assert_eq!(fresh.combine_columns, h.state().combine_columns); + // Malformed settings give the default layout. + fresh.restore(r#"{"combine_columns":{"order":["size","size"],"widths":[1,2]}}"#); + assert_eq!(fresh.combine_columns, Default::default()); +} + +#[test] +fn a_folder_adds_its_pdfs_in_order_and_optionally_its_subfolders() { + let dir = std::env::temp_dir().join(format!("pdfcraft-combine-folder-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + for (name, pages) in [("page10.pdf", 1), ("page2.PDF", 2), ("sub/inner.pdf", 3)] { + std::fs::write(dir.join(name), fixture(pages)).unwrap(); + } + std::fs::write(dir.join("notes.txt"), b"not a pdf").unwrap(); + let mut h = harness(1, |_| {}); + h.state_mut().execute("page.combine"); + h.run_steps(2); + h.state_mut().pick_override = Some(vec![dir.to_string_lossy().into_owned()]); + h.get_by_label("Add folder…").click(); + h.run_steps(4); + assert_eq!(combine_names(&h), ["page2.PDF", "page10.pdf"], "PDFs only, numbers by value"); + // The toolbar's menu: the folder and its subfolders, as one undo step. + h.get_by_label("More ways to add files").click(); + h.run_steps(2); + h.get_by_label("Add folder and subfolders…").click(); + h.run_steps(4); + assert_eq!(combine_names(&h), ["page2.PDF", "page10.pdf", "page2.PDF", "page10.pdf", "inner.pdf"]); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(2); + assert_eq!(combine_names(&h).len(), 2); + let _ = std::fs::remove_dir_all(&dir); +} + +fn type_password(h: &mut Harness<'static, PdfCraftApp>, password: &str) { + let field = h.get_by_role(Role::PasswordInput); + field.focus(); + field.type_text(password); + h.run_steps(1); + h.key_press(Key::Enter); + h.run_steps(3); +} + +#[test] +fn a_password_protected_file_is_unlocked_with_its_password_and_combined() { + let mut h = combine_of(&[("one.pdf", 1)]); + h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("secret.pdf".into(), protected("pw", "owner", -1))]); + h.run_steps(3); + h.get_by_label("Password-protected"); + assert!(h.get_by_label("Combine").accesskit_node().is_disabled()); + // The row's link (the toolbar's Unlock… comes first). + h.get_all_by_label("Unlock…").last().unwrap().click(); + h.run_steps(3); + h.get_by_label("Unlock secret.pdf"); + type_password(&mut h, "nope"); + h.get_by_label("Wrong password"); + assert!(h.state().combine_draft[1].lock.is_some()); + // The box stays open for another try. + let field = h.get_by_role(Role::PasswordInput); + field.focus(); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + type_password(&mut h, "pw"); + let f = &h.state().combine_draft[1]; + assert!(f.lock.is_none() && f.problem.is_none()); + assert_eq!(f.pages, 2); + h.get_by_label("Unlocked: the combined file won't be password-protected"); + // Never printed: debug output ends up in logs. + assert!(!format!("{:?} {:?}", h.state().combine_draft, h.state().combine_tab).contains("pw\"")); + h.get_by_label("Combine").click(); + h.run_steps(3); + assert_eq!(texts_of(h.state(), 1), ["Page 1", "Page 1", "Page 2"]); +} + +#[test] +fn one_password_unlocks_several_selected_files_and_undo_locks_them_again() { + let mut h = combine_of(&[("one.pdf", 1)]); + h.state_mut().use_files( + pdfcraft_ui_egui::FilePurpose::Combine, + vec![ + ("a.pdf".into(), protected("same", "o1", -1)), + ("b.pdf".into(), protected("same", "o2", -1)), + ("c.pdf".into(), protected("other", "o3", -1)), + ], + ); + h.run_steps(3); + h.state_mut().select_combine_rows(&[1, 2, 3]); + h.run_steps(1); + h.get_all_by_label("Unlock…").next().unwrap().click(); // the toolbar's (the rows' links come after it) + h.run_steps(3); + h.get_by_label("Unlock 3 files"); + type_password(&mut h, "same"); + let locked: Vec = h.state().combine_draft.iter().map(|f| f.lock.is_some()).collect(); + assert_eq!(locked, [false, false, false, true]); + h.get_by_label_contains("Unlocked 2 of 3 files"); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(2); + assert!(h.state().combine_draft[1..].iter().all(|f| f.lock.is_some()), "undo locks them again"); +} + +#[test] +fn a_file_that_opens_but_forbids_combining_takes_the_permissions_password() { + let mut h = combine_of(&[("one.pdf", 1)]); + // Opens with "pw"; only the owner may assemble pages. + h.state_mut().use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("both.pdf".into(), protected("pw", "owner", -1 ^ 1024 ^ 8))]); + h.run_steps(3); + assert!(h.state_mut().combine_unlock_rows(&[1], "pw")); + h.run_steps(2); + assert_eq!(h.state().combine_draft[1].lock, Some(pdfcraft_ui_egui::CombineLock::Permissions)); + h.get_by_label_contains("Enter the permissions password"); + h.get_all_by_label("Unlock…").last().unwrap().click(); + h.run_steps(3); + h.get_by_label("Enter the permissions password to allow combining"); + type_password(&mut h, "owner"); + assert!(h.state().combine_draft[1].lock.is_none()); + assert!(!h.get_by_label("Combine").accesskit_node().is_disabled()); +} + +#[test] +fn an_rc4_file_unlocks_with_its_owner_password_too() { + // The engine reads RC4 files with their owner password; the inspector may not, and the + // engine's answer is the one that counts. + let mut h = combine_of(&[("one.pdf", 1)]); + h.state_mut().use_files( + pdfcraft_ui_egui::FilePurpose::Combine, + vec![("rc4.pdf".into(), protected_with(pdfcraft_cos::Algorithm::Rc4_128, "user", "boss", -1))], + ); + h.run_steps(3); + assert!(h.state_mut().combine_unlock_rows(&[1], "boss")); + let f = &h.state().combine_draft[1]; + assert!(f.lock.is_none()); + assert_eq!(f.pages, 2); +} + +#[test] +fn open_documents_can_be_added_to_combine() { + let mut h = harness(2, |_| {}); + h.state_mut().execute("page.combine"); + h.run_steps(3); + h.get_by_label("Add open documents").click(); + h.run_steps(2); + // The menu's entry, after the tab of the same name. + h.get_all_by_label("doc.pdf").last().unwrap().click(); + h.run_steps(3); + let app = h.state(); + assert_eq!(app.combine_draft.len(), 1); + assert_eq!(app.combine_draft[0].pages, 2); + assert!(app.combine_showing()); } #[test] @@ -601,9 +1007,13 @@ fn split_before_selected_pages() { // ── Encrypted documents ─────────────────────────────────────────────────────────────────────── fn protected(user: &str, owner: &str, permissions: i32) -> Vec { + protected_with(pdfcraft_cos::Algorithm::Aes256, user, owner, permissions) +} + +fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec { let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap(); doc.set_encryption(&pdfcraft_cos::NewEncryption { - algorithm: pdfcraft_cos::Algorithm::Aes256, + algorithm, user_password: user, owner_password: owner, permissions, diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index e63c8b72..5299c664 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -256,6 +256,46 @@ fn dropping_a_pdf_on_the_window_opens_it() { h.get_by_label_contains("dropped.pdf"); } +#[test] +fn pdfs_dropped_on_the_combine_tab_join_its_list() { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| PdfCraftApp::new()); + h.run_steps(3); + h.state_mut().execute("page.combine"); + h.run_steps(2); + // An absolute path is read from disk, with its modified time. + let path = std::env::temp_dir().join(format!("pdfcraft-combine-drop-{}.pdf", std::process::id())); + std::fs::write(&path, FIXTURE).unwrap(); + let on_disk: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: path.clone(), bytes: Vec::new() }); + let in_memory: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: "second.pdf".into(), bytes: FIXTURE.to_vec() }); + h.input_mut().dropped_files.extend([on_disk, in_memory]); + h.run_steps(3); + std::fs::remove_file(&path).ok(); + let app = h.state(); + assert!(app.views.is_empty(), "nothing opens"); + assert_eq!(app.combine_draft.len(), 2); + assert!(app.combine_draft[0].modified.is_some() && app.combine_draft[1].modified.is_none()); + h.get_by_label("just now"); +} + +#[test] +fn a_folder_dropped_on_the_combine_tab_adds_its_pdfs() { + let dir = std::env::temp_dir().join(format!("pdfcraft-combine-drop-dir-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("inner")).unwrap(); + std::fs::write(dir.join("one.pdf"), FIXTURE).unwrap(); + std::fs::write(dir.join("inner").join("two.pdf"), FIXTURE).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| PdfCraftApp::new()); + h.run_steps(3); + h.state_mut().execute("page.combine"); + h.run_steps(2); + let folder: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: dir.clone(), bytes: Vec::new() }); + h.input_mut().dropped_files.push(folder); + h.run_steps(3); + let _ = std::fs::remove_dir_all(&dir); + let names: Vec<_> = h.state().combine_draft.iter().map(|f| f.name.clone()).collect(); + assert_eq!(names, ["two.pdf", "one.pdf"], "subfolders included, in path order"); +} + #[test] fn files_and_quit_from_the_operating_system() { // #73: macOS hands Finder double-clicks, Open With and Dock drops over as Apple events. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9cfa9f7b..777ca4a9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2890,8 +2890,8 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/automation/tests/automation.rs::combine_extract_and_split"] -notes = "" +evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/editing.rs::a_folder_adds_its_pdfs_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/automation/tests/automation.rs::combine_extract_and_split", "crates/automation/tests/automation.rs::combine_opens_protected_files_with_their_passwords"] +notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step." [[feature]] id = "organize.combine-bookmarks" @@ -2915,7 +2915,7 @@ status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] evidence = ["crates/ui-egui/tests/editing.rs::combine_files_takes_chosen_pages_in_the_order_listed", "crates/automation/tests/automation.rs::combine_extract_and_split"] -notes = "Combine files list: order (move up/down), remove, add files, a page range per file (such as 1-3, 6; in the order given); bookmarks to left-out pages lose their destination" +notes = "A page range per file (such as 1-3, 6; in the order given), checked as it is typed, with the page count it takes; bookmarks to left-out pages lose their destination" [[feature]] id = "organize.combine-sort" @@ -2923,11 +2923,11 @@ title = "Sort and reorder files before combining" area = "D" tier = "P2" milestone = "M4" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["page.combine"] +tools = ["doc_combine"] +evidence = ["crates/ui-egui/tests/editing.rs::column_headings_sort_the_list_and_flip_on_the_second_click", "crates/ui-egui/tests/editing.rs::ctrl_and_shift_clicks_select_several_files_that_move_and_go_together", "crates/ui-egui/tests/editing.rs::undo_and_redo_restore_the_combine_list", "crates/automation/tests/automation.rs::combine_extract_and_split"] +notes = "Sort by any column (name in natural order, pages, size, modified, warnings), ascending or descending; multi-select (Ctrl/Shift) and move up/down, Alt+arrows or drag; every change undoable. doc_combine combines in the order its paths are given." [[feature]] id = "organize.combine-options" @@ -2935,11 +2935,11 @@ title = "Combine options (file size, convert to PDF/A, include open files)" area = "D" tier = "P2" milestone = "M11" -status = "planned" -commands = [] +status = "partial" +commands = ["page.combine"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/editing.rs::open_documents_can_be_added_to_combine"] +notes = "Include open files: Add open documents (with their unsaved changes). Missing: file-size options and converting the result to PDF/A." [[feature]] id = "organize.resource-dedupe" From ca9541069fda2996d2506a768feb1709ead6c167 Mon Sep 17 00:00:00 2001 From: Fazmin Nizam Date: Thu, 8 Oct 2026 15:37:21 -0400 Subject: [PATCH 003/149] Issue #236: the Save-changes prompt shortens very long names to fit the window (#322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrapping the title (#161) kept the prompt narrow, but a long enough name still grew it taller than the window and pushed Save, Cancel and Don't save off-screen. On the web a `?file=` URL names the document, so the name has no length limit. Past 80 characters the name now gives way in the middle ("start…end"), keeping the start and the extension and version suffix; the full name shows on hover. The new test checks the title and buttons stay inside the desktop's minimum window (820×520) for long Latin, Thai and CJK names and a 2000-character one. Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/dialogs.rs | 23 +++++++++++++++++-- crates/ui-egui/tests/editing.rs | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f1243f2d..14135534 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1344,18 +1344,24 @@ fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { return; }; let t = Tokens::get(ctx); + // Wrapping alone (#161) still let a long enough name (a web `?file=` URL has no limit) grow + // the prompt taller than the window (#236); 80 characters wrap to a few lines. + let shown = shorten_middle(&name, 80); let mut choice: Option> = None; let modal = egui::Modal::new(egui::Id::new("save_prompt")).show(ctx, |ui| { ui.set_width(420.0); ui.horizontal(|ui| { ui.add(crate::icons::image("save", 22.0, t.accent)); - ui.add( + let title = ui.add( egui::Label::new( - egui::RichText::new(crate::i18n::fmt(tl!("Save changes to “{name}” before closing?"), &[("name", &name)])) + egui::RichText::new(crate::i18n::fmt(tl!("Save changes to “{name}” before closing?"), &[("name", &shown)])) .font(theme::semibold(16.0)), ) .wrap(), ); + if shown != name { + title.on_hover_text(&name); + } }); ui.add_space(6.0); ui.label(egui::RichText::new(tl!("Your changes will be lost if you don't save them.")).color(t.text_muted)); @@ -1381,6 +1387,19 @@ fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { } } +/// `name` cut to at most `max` characters by replacing its middle with "…", keeping the start and +/// the end, where the extension and version suffixes sit. Counts `char`s, so it never splits one. +fn shorten_middle(name: &str, max: usize) -> String { + let count = name.chars().count(); + if count <= max { + return name.to_string(); + } + let tail = max / 4; + let head: String = name.chars().take(max.saturating_sub(tail + 1)).collect(); + let end: String = name.chars().skip(count.saturating_sub(tail)).collect(); + format!("{head}…{end}") +} + /// "Open this web page?" when a document's link, button or script asks to open an address /// (#90, #91). Shows where the address really goes and the whole address; Cancel is the default, /// and Escape or clicking outside cancels. diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 21baf309..3aed0f6c 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -411,6 +411,46 @@ fn save_prompt_stays_inside_the_screen_for_a_long_filename() { } } +#[test] +fn save_prompt_fits_the_smallest_window_whatever_the_name() { + // Issue #236: wrapping (#161) kept the prompt narrow, but a long enough name still grew it + // taller than the window and pushed the buttons off-screen. On the web a `?file=` URL names + // the document, so the name has no length limit. Long names now give way in the middle. + let names = [ + format!("{}.pdf", "a".repeat(400)), + format!("{}.pdf", "Quarterly_Report_FY2026_Final_v3_".repeat(12)), + format!("{}.pdf", "รายงานประจำปีงบประมาณ".repeat(15)), + format!("{}.pdf", "年".repeat(251)), + format!("{}.pdf", "W".repeat(2000)), + ]; + // The desktop window's minimum inner size (apps/pdfcraft/src/main.rs). + let size = egui::vec2(820.0, 520.0); + for name in names { + let start: String = name.chars().take(10).collect(); + let mut h = Harness::builder().with_size(size).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.open_bytes(&name, None, fixture(1)).expect("fixture opens"); + app.close_request = Some(CloseRequest::Tab(app.views[0].id)); + app + }); + h.run_steps(4); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + let inside = |r: egui::Rect| screen.contains(r.min) && screen.contains(r.max); + let title = h.get_by_label_contains("Save changes to"); + let title_rect = title.rect(); + assert!(inside(title_rect), "{start}…: the title rect {title_rect:?} leaves the screen"); + let opening = format!("Save changes to “{start}"); + assert!( + h.query_by(|n| n.value().is_some_and(|l| l.starts_with(&opening) && l.contains('…') && l.contains(".pdf” before closing?"))).is_some(), + "{start}…: the title keeps the start and the extension" + ); + for button in ["Save", "Cancel", "Don't save"] { + let rect = h.get_by_label(button).rect(); + assert!(inside(rect), "{start}…: the {button} button rect {rect:?} leaves the screen"); + } + } +} + #[test] fn document_properties_edit_is_one_undoable_step() { let mut h = harness(1, |app| app.set_option("dialog", "properties").unwrap()); From a21637c7572be84492054dc0b8c68b95f1a18092 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 Date: Thu, 8 Oct 2026 19:55:42 +0000 Subject: [PATCH 004/149] M14: reject invalid combine page selector types (#329) Validate every doc_combine selector before reading inputs or changing output and session state. Cover invalid types and saved/reopened selection order. Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Co-authored-by: Codex Co-authored-by: Brandon Thomas --- crates/automation/src/lib.rs | 10 ++- crates/automation/tests/automation.rs | 109 ++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 952f2ff9..35dbadf7 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1175,7 +1175,15 @@ impl Automation { } let ranges: Vec> = match a.get("pages") { None | Some(Value::Null) => vec![None; paths.len()], - Some(Value::Array(v)) if v.len() == paths.len() => v.iter().map(|x| x.as_str().map(str::to_owned)).collect(), + Some(Value::Array(v)) if v.len() == paths.len() => v + .iter() + .enumerate() + .map(|(index, value)| match value { + Value::Null => Ok(None), + Value::String(range) => Ok(Some(range.clone())), + _ => Err(ToolError::InvalidArgs(format!("pages[{index}] must be a range string or null"))), + }) + .collect::>()?, Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), }; let passwords: Vec> = match a.get("passwords") { diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 65e5702d..b3ac82e9 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2349,3 +2349,112 @@ mod close_argument_tests { assert_eq!(std::fs::read(dir.0.join("a.pdf")).unwrap(), source); } } + +mod combine_argument_tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + // Own only a newly created directory; the bounded runner provides project-local TMPDIR. + struct CombineDir(PathBuf); + + impl CombineDir { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let parent = std::env::temp_dir(); + for _ in 0..128 { + let serial = NEXT.fetch_add(1, Ordering::Relaxed); + let path = parent.join(format!("pdfcraft-combine-{}-{serial}", std::process::id())); + match std::fs::create_dir(&path) { + Ok(()) => { + let dir = Self(path); + std::fs::write(dir.0.join("a.pdf"), fixture(3)).unwrap(); + std::fs::write(dir.0.join("b.pdf"), fixture(2)).unwrap(); + return dir; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("creating combine test directory: {error}"), + } + } + panic!("no unused combine test directory after 128 attempts"); + } + } + + impl Drop for CombineDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn invalid_combine_selectors_preserve_outputs_inputs_and_open_documents() { + let dir = CombineDir::new(); + let first = std::fs::read(dir.0.join("a.pdf")).unwrap(); + let second = std::fs::read(dir.0.join("b.pdf")).unwrap(); + let sentinel = b"existing destination"; + std::fs::write(dir.0.join("combined.pdf"), sentinel).unwrap(); + let mut a = auto(&dir.0); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Unsaved title" })); + ok(&mut a, "doc_open", json!({ "path": "b.pdf" })); + let list = ok(&mut a, "doc_list", json!({})); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + let text = page_text(&mut a, doc); + let bytes = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.clone(); + for invalid in [json!(1), json!(true), json!({ "page": 1 }), json!(["1"])] { + for (index, pages) in [json!([invalid, null]), json!([null, invalid])].into_iter().enumerate() { + for out in ["combined.pdf", "not-created.pdf"] { + let error = a.call("doc_combine", &json!({ "paths": ["a.pdf", "b.pdf"], "pages": pages, "out": out, "open": true })).unwrap_err(); + assert!( + matches!(error, ToolError::InvalidArgs(ref message) if message == &format!("pages[{index}] must be a range string or null")) + ); + assert_eq!(std::fs::read(dir.0.join("combined.pdf")).unwrap(), sentinel); + assert!(!dir.0.join("not-created.pdf").exists()); + assert_eq!(ok(&mut a, "doc_list", json!({})), list); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc })), info); + assert_eq!(page_text(&mut a, doc), text); + assert_eq!(a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes, bytes); + assert_eq!(std::fs::read(dir.0.join("a.pdf")).unwrap(), first); + assert_eq!(std::fs::read(dir.0.join("b.pdf")).unwrap(), second); + } + } + } + let error = a + .call("doc_combine", &json!({ "paths": ["missing-a.pdf", "missing-b.pdf"], "pages": [null, false], "out": "not-created.pdf" })) + .unwrap_err(); + assert!(matches!(error, ToolError::InvalidArgs(ref message) if message == "pages[1] must be a range string or null")); + assert_eq!(ok(&mut a, "doc_list", json!({})), list); + assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 3, "no output or staging files are created"); + } + + #[test] + fn valid_combine_selectors_keep_all_pages_and_selected_order_after_reopen() { + let dir = CombineDir::new(); + let first = std::fs::read(dir.0.join("a.pdf")).unwrap(); + let second = std::fs::read(dir.0.join("b.pdf")).unwrap(); + let mut a = auto(&dir.0); + let all = vec!["Page 1", "Page 2", "Page 3", "Page 1", "Page 2"]; + for (pages, expected) in [ + (None, all.clone()), + (Some(Value::Null), all.clone()), + (Some(json!([null, null])), all.clone()), + (Some(json!(["", null])), all), + (Some(json!(["3, 1", null])), vec!["Page 3", "Page 1", "Page 1", "Page 2"]), + (Some(json!([null, "2"])), vec!["Page 1", "Page 2", "Page 3", "Page 2"]), + ] { + let mut args = json!({ "paths": ["a.pdf", "b.pdf"], "out": "combined.pdf", "open": false }); + if let Some(pages) = pages { + args["pages"] = pages; + } + let result = ok(&mut a, "doc_combine", args); + assert!(result["bytes"].as_u64().unwrap() > 0); + assert!(result.get("document").is_none()); + assert!(a.session().docs().is_empty()); + let mut fresh = auto(&dir.0); + let opened = ok(&mut fresh, "doc_open", json!({ "path": "combined.pdf" })); + assert_eq!(opened["pages"].as_u64().unwrap(), expected.len() as u64); + assert_eq!(page_text(&mut fresh, opened["doc"].as_u64().unwrap()), expected); + assert_eq!(std::fs::read(dir.0.join("a.pdf")).unwrap(), first); + assert_eq!(std::fs::read(dir.0.join("b.pdf")).unwrap(), second); + } + } +} From a2d92daa00d5ac508e64ab59d879623b2ec852af Mon Sep 17 00:00:00 2001 From: Fazmin Nizam Date: Thu, 8 Oct 2026 16:04:03 -0400 Subject: [PATCH 005/149] Issue #263: combine, extract and split keep the PDF/X output intent and identification (#334) Combine, extract and split built the new document from copies of the pages and carried bookmarks, fields, layers, attachments and four Info keys, but not the catalog's /OutputIntents, the GTS_PDFXVersion, GTS_PDFXConformance and Trapped Info entries, or the XMP pdfxid identification. A PDF/X file stopped being one, and a RIP or preflight lost the intended output condition. Extract and split now carry the output intents (with their ICC profiles, through the same object map as the pages), the Info entries (the version read from Info, else XMP) and a new XMP packet holding the identification, trapping and title. The source's packet is not copied: it can claim standards the new file no longer meets, such as PDF/UA, whose structure tree is not copied. Combine carries the first source's standard only when every source declares the same version, trapping and output intents (type, condition and profile bytes). Not yet: a warning when combine's sources disagree, and XMP dates. Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/organize/src/import.rs | 58 +++++++--- crates/organize/src/lib.rs | 12 ++- crates/organize/src/pdfx.rs | 195 ++++++++++++++++++++++++++++++++++ crates/organize/src/tests.rs | 161 ++++++++++++++++++++++++++++ parity/acrobat-features.toml | 12 +-- 5 files changed, 414 insertions(+), 24 deletions(-) create mode 100644 crates/organize/src/pdfx.rs diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 05c481f8..9b68dc2e 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -181,6 +181,13 @@ impl Copier<'_> { } } +/// Copy `o` from `src` into `dst`, reusing the copies listed in `map` (source → destination), so +/// an object the copied pages already brought along (an ICC profile) is not stored twice. +pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: HashMap) -> Object { + let mut c = Copier { src, map, pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() }; + c.copy_value(dst, o, None) +} + /// Copy pages `src_pages` (0-based, in the given order) of `src` into `dst`, inserting them at /// position `at`. Returns the new page references in order. /// @@ -188,19 +195,24 @@ impl Copier<'_> { /// shared rather than stored twice (see `dedupe`). pub fn import_pages(dst: &mut Document, src: &Document, src_pages: &[usize], at: usize) -> Result, OrganizeError> { let first_new = dst.object_numbers().last().map_or(1, |n| n + 1); - let pages = import_pages_mapped(dst, src, src_pages, at)?.0; + let pages = import_pages_mapped(dst, src, src_pages, at)?.pages; let created: Vec = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect(); crate::dedupe::dedupe_resources(dst, &created, true); Ok(pages) } -/// `import_pages`, also returning the source-page → destination-page map. -fn import_pages_mapped( - dst: &mut Document, - src: &Document, - src_pages: &[usize], - at: usize, -) -> Result<(Vec, HashMap), OrganizeError> { +/// What `import_pages_mapped` copied. +struct Imported { + /// The new pages, in order. + pages: Vec, + /// Source page → destination page. + page_map: HashMap, + /// Every source object copied → its copy. + objects: HashMap, +} + +/// `import_pages`, also returning what was copied from where. +fn import_pages_mapped(dst: &mut Document, src: &Document, src_pages: &[usize], at: usize) -> Result { let source = walk(src)?; if let Some(bad) = src_pages.iter().find(|i| **i >= source.len()) { return Err(OrganizeError::NoSuchPage(*bad)); @@ -278,7 +290,7 @@ fn import_pages_mapped( let inserted: Vec<(ObjRef, Dict)> = new_pages.iter().map(|r| (*r, Dict::new())).collect(); existing.splice(at..at, inserted); rebuild(dst, &existing)?; - Ok((new_pages, copier.pages)) + Ok(Imported { pages: new_pages, page_map: copier.pages, objects: copier.map }) } /// Look up a named destination in the source (`/Dests` dictionary or `/Names /Dests` tree). @@ -410,16 +422,19 @@ fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), Organize } /// A new document containing copies of `pages` from `src` (Extract Pages / Split). -/// Document information (title, author…) is carried over. +/// Document information (title, author…) is carried over, and so is the print standard the +/// source declares (PDF/X: its output intents and identification, #263). pub fn extract_pages(src: &Document, pages: &[usize]) -> Result { let mut out = Document::new_empty(); // One source: nothing to deduplicate. - import_pages_mapped(&mut out, src, pages, 0)?; + let imported = import_pages_mapped(&mut out, src, pages, 0)?; for key in crate::INFO_KEYS { if let Some(v) = crate::info(src, key) { crate::set_info(&mut out, key, &v)?; } } + // After the title: the XMP packet repeats it. + crate::pdfx::carry(&mut out, src, &crate::pdfx::PrintStandard::of(src), imported.objects)?; // The pages share their source's resource dictionary, so the part would otherwise carry every // XObject the source lists — images and all (#204). Keep only what these pages draw. crate::prune::prune_unused_xobjects(&mut out)?; @@ -463,7 +478,8 @@ pub fn split(src: &Document, by: &SplitBy) -> Result, OrganizeErro /// Combine whole documents, in order, into a new document. Each source gets a top-level /// bookmark (its `title`) pointing to its first page, like Acrobat's Combine Files. /// The source's own bookmarks are nested (collapsed) under its entry, and its document-level -/// attachments are carried over. +/// attachments are carried over. When every source declares the same print standard (PDF/X +/// with the same output intent), so does the result. pub fn combine(sources: &[(&str, &Document)]) -> Result { let all: Vec<(&str, &Document, Option<&[usize]>)> = sources.iter().map(|(t, d)| (*t, *d, None)).collect(); combine_selected(&all) @@ -475,6 +491,10 @@ pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Resu let mut out = Document::new_empty(); let mut marks = Vec::new(); let mut attachments = Vec::new(); + // The first source's print standard, and what its pages brought along; it carries over + // only when every source declares the same one. + let mut standard: Option<(crate::pdfx::PrintStandard, &Document, HashMap)> = None; + let mut agreed = true; for (title, src, chosen) in sources { let n = crate::page_count(src)?; let pages: Vec = match chosen { @@ -487,11 +507,19 @@ pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Resu None => (0..n).collect(), }; let at = crate::page_count(&out)?; - let (new, page_map) = import_pages_mapped(&mut out, src, &pages, at)?; - if let Some(first) = new.first() { - marks.push((title.to_string(), *first, *src, page_map)); + let imported = import_pages_mapped(&mut out, src, &pages, at)?; + if let Some(first) = imported.pages.first() { + marks.push((title.to_string(), *first, *src, imported.page_map)); } collect_attachments(&mut out, src, &mut attachments); + let declared = crate::pdfx::PrintStandard::of(src); + match &standard { + None => standard = Some((declared, *src, imported.objects)), + Some((first, _, _)) => agreed &= first.same_as(&declared), + } + } + if let Some((declared, src, objects)) = standard.filter(|_| agreed) { + crate::pdfx::carry(&mut out, src, &declared, objects)?; } // Sources often share fonts, images and profiles (or are the same file): store them once. let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 0a63a085..1b5d8347 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -18,6 +18,7 @@ mod dedupe; mod import; mod labels; mod outline; +mod pdfx; mod prune; pub mod view; @@ -347,12 +348,17 @@ pub fn info(doc: &Document, key: &str) -> Option { pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { let value = value.trim(); let entry = (!value.is_empty()).then(|| Object::String(PdfString::text(value))); + set_info_entry(doc, key.as_bytes(), entry) +} + +/// Set (or remove, with `None`) a document-information entry of any type. +fn set_info_entry(doc: &mut Document, key: &[u8], entry: Option) -> Result<(), OrganizeError> { match doc.trailer().get(b"Info").cloned() { Some(Object::Ref(r)) if doc.get(r).as_dict().is_some() => { doc.update_dict(r, |d| match entry { - Some(v) => d.set(key.as_bytes().to_vec(), v), + Some(v) => d.set(key.to_vec(), v), None => { - d.remove(key.as_bytes()); + d.remove(key); } })?; } @@ -362,7 +368,7 @@ pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), Organi Some(Object::Dict(d)) => d.clone(), _ => Dict::new(), }; - d.set(key.as_bytes().to_vec(), v); + d.set(key.to_vec(), v); let r = doc.add(d); doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(r)); } diff --git a/crates/organize/src/pdfx.rs b/crates/organize/src/pdfx.rs new file mode 100644 index 00000000..eb240d1a --- /dev/null +++ b/crates/organize/src/pdfx.rs @@ -0,0 +1,195 @@ +//! The print standard a document declares (PDF/X, ISO 15930), carried into the new documents +//! Extract, Split and Combine make from its pages (#263). Apart from its pages, a PDF/X file is +//! the catalog's `/OutputIntents` (the printing condition and its ICC profile) and the version +//! it declares, in the document information (`GTS_PDFXVersion`, `GTS_PDFXConformance`, +//! `Trapped`) and in its XMP metadata (`pdfxid:`). +//! +//! The new document gets an XMP packet of its own, holding that identification and the title. +//! The source's packet is not copied: it can also claim standards the new document no longer +//! meets, such as PDF/UA, whose structure tree is not copied. + +use std::collections::HashMap; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; + +use crate::OrganizeError; + +/// Decoding limit for an output profile or an XMP packet (ICC profiles are a few MB at most). +const MAX_DECODED: usize = 64 << 20; + +/// What a document declares about the print standard it meets. +#[derive(Debug, Default)] +pub(crate) struct PrintStandard { + /// The catalog's `/OutputIntents`, as written. + intents: Option, + /// Each output intent's type (`/S`), printing condition and decoded profile, to compare. + conditions: Vec<(Vec, Vec, Vec)>, + /// `GTS_PDFXVersion` ("PDF/X-4"), from the document information or else the XMP metadata. + version: Option, + /// `GTS_PDFXConformance` ("PDF/X-1a:2001"), which PDF/X-1a and PDF/X-3 add. + conformance: Option, + /// `Trapped`. + trapped: Option<&'static str>, + /// Whether the document has XMP metadata (a PDF/X-1a:2001 file, PDF 1.3, has none). + xmp: bool, +} + +impl PrintStandard { + /// What `doc` declares. + pub(crate) fn of(doc: &Document) -> Self { + let catalog = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap_or_default(); + let intents = catalog.get(b"OutputIntents").cloned(); + let list = intents.as_ref().and_then(|o| doc.resolve(o).as_array().cloned()).unwrap_or_default(); + let conditions = list + .iter() + .map(|oi| { + let oi = doc.resolve(oi); + let d = oi.as_dict(); + let kind = d.and_then(|d| d.name(b"S")).unwrap_or_default().to_vec(); + let id = d.and_then(|d| d.get(b"OutputConditionIdentifier")).and_then(|v| doc.resolve(v).as_string().map(|s| s.bytes.clone())); + let profile = d.and_then(|d| d.get(b"DestOutputProfile")).map(|p| doc.resolve(p)).and_then(|p| match &*p { + // Undecodable: compare the bytes as stored. + Object::Stream(s) => Some(s.decoded_within(MAX_DECODED).unwrap_or_else(|_| s.raw.as_ref().clone())), + _ => None, + }); + (kind, id.unwrap_or_default(), profile.unwrap_or_default()) + }) + .collect(); + let xmp = metadata(doc, &catalog); + let declared = |info_key: &str, xmp_key: &str| { + crate::info(doc, info_key) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| xmp.as_deref().and_then(|x| xmp_value(x, xmp_key))) + }; + let trapped = info_trapped(doc).or_else(|| xmp.as_deref().and_then(|x| xmp_value(x, "pdf:Trapped")).and_then(|t| trapped(t.as_bytes()))); + Self { + intents, + conditions, + version: declared("GTS_PDFXVersion", "pdfxid:GTS_PDFXVersion"), + conformance: declared("GTS_PDFXConformance", "pdfxid:GTS_PDFXConformance"), + trapped, + xmp: xmp.is_some(), + } + } + + /// Whether `other` declares the same standard: the same version and trapping, and the same + /// output intents (type, printing condition and profile). + pub(crate) fn same_as(&self, other: &Self) -> bool { + self.conditions == other.conditions && self.version == other.version && self.conformance == other.conformance && self.trapped == other.trapped + } +} + +/// Give `dst` the print standard `src` declares: its output intents, copied with their profiles +/// (through `map`, what the pages already brought along), and, for PDF/X, the document +/// information entries and the XMP metadata that identify it. +pub(crate) fn carry(dst: &mut Document, src: &Document, standard: &PrintStandard, map: HashMap) -> Result<(), OrganizeError> { + let root = dst.root().ok_or(OrganizeError::NoPageTree)?; + if let Some(intents) = &standard.intents { + let copied = crate::import::copy_object(dst, src, intents, map); + dst.update_dict(root, |c| c.set(b"OutputIntents".to_vec(), copied))?; + } + let Some(version) = &standard.version else { return Ok(()) }; + crate::set_info_entry(dst, b"GTS_PDFXVersion", Some(Object::String(PdfString::text(version))))?; + if let Some(c) = &standard.conformance { + crate::set_info_entry(dst, b"GTS_PDFXConformance", Some(Object::String(PdfString::text(c))))?; + } + if let Some(t) = standard.trapped { + crate::set_info_entry(dst, b"Trapped", Some(Object::name(t)))?; + } + if standard.xmp { + let mut md = Dict::new(); + md.set(b"Type".to_vec(), Object::name("Metadata")); + md.set(b"Subtype".to_vec(), Object::name("XML")); + // Uncompressed, so other tools can find it. + let m = dst.add(Object::Stream(Stream::from_raw(md, packet(dst, standard, version).into_bytes()))); + dst.update_dict(root, |c| c.set(b"Metadata".to_vec(), Object::Ref(m)))?; + } + Ok(()) +} + +/// The XMP packet identifying `doc` as `version`, with its title and trapping. +fn packet(doc: &Document, standard: &PrintStandard, version: &str) -> String { + let mut props = String::new(); + if let Some(t) = crate::info(doc, "Title").filter(|t| !t.trim().is_empty()) { + props.push_str(&format!(" {}\n", esc(&t))); + } + if let Some(t) = standard.trapped { + props.push_str(&format!(" {t}\n")); + } + props.push_str(&format!(" {}\n", esc(version))); + if let Some(c) = &standard.conformance { + props.push_str(&format!(" {}\n", esc(c))); + } + format!( + "\n\ +\n\ + \n\ + \n\ +{props} \n\ + \n\ +\n\ +" + ) +} + +/// The document information's `/Trapped` (a name, or a string in older files). +fn info_trapped(doc: &Document) -> Option<&'static str> { + let info = doc.resolve(doc.trailer().get(b"Info")?); + let v = doc.resolve(info.as_dict()?.get(b"Trapped")?); + match &*v { + Object::Name(n) => trapped(n), + Object::String(s) => trapped(&s.bytes), + _ => None, + } +} + +fn trapped(v: &[u8]) -> Option<&'static str> { + match v { + b"True" => Some("True"), + b"False" => Some("False"), + b"Unknown" => Some("Unknown"), + _ => None, + } +} + +/// The catalog's XMP metadata, as text. +fn metadata(doc: &Document, catalog: &Dict) -> Option { + match &*doc.resolve(catalog.get(b"Metadata")?) { + Object::Stream(s) => s.decoded_within(MAX_DECODED).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + _ => None, + } +} + +/// A simple XMP property, written as an attribute (`pdfxid:GTS_PDFXVersion="PDF/X-4"`) or as an +/// element (`PDF/X-4`). +fn xmp_value(xmp: &str, key: &str) -> Option { + for quote in ['"', '\''] { + let attr = format!("{key}={quote}"); + if let Some(rest) = xmp.find(&attr).and_then(|i| xmp.get(i + attr.len()..)) { + return rest.find(quote).and_then(|end| rest.get(..end)).map(unescape).filter(|v| !v.is_empty()); + } + } + let open = format!("<{key}>"); + let rest = xmp.get(xmp.find(&open)? + open.len()..)?; + let value = rest.get(..rest.find(&format!(""))?)?; + Some(unescape(value.trim())).filter(|v| !v.is_empty()) +} + +fn unescape(s: &str) -> String { + s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&") +} + +/// `s` as XML text: markup escaped, and characters XML 1.0 does not allow left out. +fn esc(s: &str) -> String { + s.chars() + .filter(|c| (!c.is_control() || matches!(c, '\t' | '\n' | '\r')) && !matches!(c, '\u{FFFE}' | '\u{FFFF}')) + .collect::() + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 16273e44..cff9f984 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -401,6 +401,167 @@ fn split_produces_standalone_documents_with_metadata() { } } +// ── PDF/X (#263) ─────────────────────────────────────────────────────────────────────────────── + +/// PDF/X-4 identification in XMP, as an attribute, next to a PDF/UA claim. +const X4_XMP: &str = ""; + +/// A two-page print file whose output intent prints to `condition` with `profile` as its ICC +/// profile, with `info` as its document information and `xmp` as its XMP metadata. +fn doc_print(condition: &str, profile: &str, info: &str, xmp: Option<&str>) -> Document { + let metadata = if xmp.is_some() { " /Metadata 10 0 R" } else { "" }; + let mut b: Vec = vec![ + format!("<< /Type /Catalog /Pages 2 0 R /OutputIntents [7 0 R]{metadata} >>"), // 1 + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 252 144] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /TrimBox [9 9 243 135] >>".into(), // 3 + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /TrimBox [9 9 243 135] >>".into(), // 4 + body("X1"), // 5 + body("X2"), // 6 + format!("<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier ({condition}) /DestOutputProfile 8 0 R >>"), // 7 + format!("<< /N 4 /Length {} >>\nstream\n{profile}\nendstream", profile.len()), // 8 + format!("<< {info} >>"), // 9 + ]; + if let Some(x) = xmp { + b.push(format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{x}\nendstream", x.len())); // 10 + } + open(build(&b, "/Root 1 0 R /Info 9 0 R")) +} + +/// A PDF/X-4 business card printing to `condition` with `profile`. +fn doc_x4(condition: &str, profile: &str) -> Document { + doc_print(condition, profile, "/Title (Card) /GTS_PDFXVersion (PDF/X-4) /Trapped /False", Some(X4_XMP)) +} + +/// What a document says about PDF/X. +#[derive(Debug, Default, PartialEq)] +struct Pdfx { + /// Each output intent's printing condition and decoded profile. + intents: Vec<(String, Vec)>, + version: Option, + trapped: Option>, + xmp: Option, +} + +fn pdfx_of(doc: &Document) -> Pdfx { + let cat = catalog(doc); + let list = cat.get(b"OutputIntents").map(|o| doc.resolve(o).as_array().cloned().unwrap_or_default()).unwrap_or_default(); + let intents = list + .iter() + .filter_map(|oi| { + let d = doc.resolve(oi).as_dict().cloned()?; + let id = d.get(b"OutputConditionIdentifier").and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text())).unwrap_or_default(); + let profile = match d.get(b"DestOutputProfile").map(|p| doc.resolve(p)).as_deref() { + Some(Object::Stream(s)) => s.decoded().unwrap(), + _ => Vec::new(), + }; + Some((id, profile)) + }) + .collect(); + let trapped = doc.trailer().get(b"Info").map(|i| doc.resolve(i)).and_then(|i| i.as_dict().and_then(|d| d.name(b"Trapped")).map(<[u8]>::to_vec)); + let xmp = match cat.get(b"Metadata").map(|m| doc.resolve(m)).as_deref() { + Some(Object::Stream(s)) => Some(String::from_utf8(s.decoded().unwrap()).unwrap()), + _ => None, + }; + Pdfx { intents, version: info(doc, "GTS_PDFXVersion"), trapped, xmp } +} + +#[test] +fn extract_and_split_keep_the_pdfx_output_intent_and_identification() { + // #263: the parts had no /OutputIntents, no GTS_PDFXVersion and no XMP pdfxid, so a PDF/X + // file stopped being one. + let src = doc_x4("FOGRA39", "icc-fogra39"); + let mut outs = vec![extract_pages(&src, &[1]).unwrap()]; + outs.extend(split(&src, &SplitBy::PageCount(1)).unwrap()); + for out in &outs { + let x = pdfx_of(&full_roundtrip(out)); + assert_eq!(x.intents, [("FOGRA39".to_string(), b"icc-fogra39".to_vec())]); + assert_eq!(x.version.as_deref(), Some("PDF/X-4")); + assert_eq!(x.trapped.as_deref(), Some(&b"False"[..])); + let xmp = x.xmp.expect("XMP metadata"); + assert!(xmp.contains("PDF/X-4"), "{xmp}"); + assert!(xmp.contains(">Card") && xmp.contains("False"), "{xmp}"); + // The structure tree is not copied, so the source's PDF/UA claim must not be either. + assert!(!xmp.contains("pdfuaid"), "{xmp}"); + } +} + +#[test] +fn combine_keeps_pdfx_when_every_source_agrees() { + // #263: a PDF/X-4 card combined with itself. + let (a, b) = (doc_x4("FOGRA39", "icc-fogra39"), doc_x4("FOGRA39", "icc-fogra39")); + let out = full_roundtrip(&combine(&[("a", &a), ("b", &b)]).unwrap()); + assert_eq!(labels(&out), ["X1", "X2", "X1", "X2"]); + let x = pdfx_of(&out); + assert_eq!(x.intents, [("FOGRA39".to_string(), b"icc-fogra39".to_vec())], "one output intent, not one per source"); + assert_eq!(x.version.as_deref(), Some("PDF/X-4")); + assert!(x.xmp.is_some_and(|x| x.contains("PDF/X-4"))); +} + +#[test] +fn combine_claims_no_pdfx_when_the_sources_disagree() { + // Another printing condition, the same name with another profile, or a source that is not + // PDF/X: no one standard describes the result, so it claims none. + let x4 = doc_x4("FOGRA39", "icc-fogra39"); + for other in [doc_x4("GRACoL2013", "icc-gracol"), doc_x4("FOGRA39", "icc-other"), doc_b()] { + let out = full_roundtrip(&combine(&[("a", &x4), ("b", &other)]).unwrap()); + assert_eq!(pdfx_of(&out), Pdfx::default()); + } +} + +#[test] +fn documents_without_a_print_standard_gain_none() { + assert_eq!(pdfx_of(&full_roundtrip(&extract_pages(&doc_a(), &[0]).unwrap())), Pdfx::default()); + assert_eq!(pdfx_of(&full_roundtrip(&combine(&[("a", &doc_a()), ("b", &doc_b())]).unwrap())), Pdfx::default()); +} + +#[test] +fn the_pdfx_version_comes_from_xmp_or_the_document_information() { + // A PDF/X-4 file may name its version only in XMP (here as an element); a PDF/X-1a:2001 file + // (PDF 1.3) only in its document information, with no XMP, and gets no XMP added. + let xmp = "\ + PDF/X-4 True"; + let x = pdfx_of(&full_roundtrip(&extract_pages(&doc_print("FOGRA51", "icc", "/Title (Flyer)", Some(xmp)), &[0]).unwrap())); + assert_eq!((x.version.as_deref(), x.trapped.as_deref()), (Some("PDF/X-4"), Some(&b"True"[..]))); + assert!(x.xmp.is_some_and(|x| x.contains("PDF/X-4"))); + + let x1a = doc_print("CGATS TR 001", "icc", "/GTS_PDFXVersion (PDF/X-1:2001) /GTS_PDFXConformance (PDF/X-1a:2001) /Trapped (False)", None); + let out = full_roundtrip(&extract_pages(&x1a, &[0]).unwrap()); + let x = pdfx_of(&out); + assert_eq!((x.version.as_deref(), x.trapped.as_deref()), (Some("PDF/X-1:2001"), Some(&b"False"[..]))); + assert_eq!(info(&out, "GTS_PDFXConformance").as_deref(), Some("PDF/X-1a:2001")); + assert_eq!(x.xmp, None); +} + +#[test] +fn malformed_output_intents_and_identification_do_not_fail() { + // Untrusted input: intents that are not dictionaries, a missing profile, an intent that is + // its own profile, an XMP packet that never closes, and a version full of markup and NULs. + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /OutputIntents [5 0 R 42 (junk) 6 0 R] /Metadata 7 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".into(), // 3 + body("M1"), // 4 + "<< /Type /OutputIntent /S /GTS_PDFX /DestOutputProfile 99 0 R >>".into(), // 5 + "<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (x) /DestOutputProfile 6 0 R >>".into(), // 6 + "<< /Length 30 >>\nstream\nX\nendstream".into(), // 7 + "<< /GTS_PDFXVersion (\\000) /Trapped /Maybe >>".into(), // 8 + ]; + let src = open(build(&b, "/Root 1 0 R /Info 8 0 R")); + let out = full_roundtrip(&extract_pages(&src, &[0]).unwrap()); + assert_eq!(labels(&out), ["M1"]); + let list = catalog(&out).get(b"OutputIntents").map(|o| out.resolve(o).as_array().cloned().unwrap_or_default()).unwrap_or_default(); + assert_eq!(list.len(), 4, "every entry is kept as written"); + let x = pdfx_of(&out); + assert_eq!(x.trapped, None, "/Maybe is not a trapping state"); + let xmp = x.xmp.expect("XMP metadata"); + assert!(xmp.contains("<PDF/X-4 & "more">"), "{xmp}"); + assert!(!xmp.contains('\0') && !xmp.contains("GTS_PDFXConformance"), "{xmp}"); +} + #[test] fn duplicate_pages_become_independent_copies() { let out = full_roundtrip(&extract_pages(&doc_b(), &[0, 0]).unwrap()); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 777ca4a9..f5ddc31c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2698,8 +2698,8 @@ milestone = "M4" status = "shipped" commands = ["page.extract"] tools = ["page_extract"] -evidence = ["crates/ui-egui/tests/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/automation.rs::combine_extract_and_split"] -notes = "" +evidence = ["crates/ui-egui/tests/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/automation.rs::combine_extract_and_split", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"] +notes = "Carries the PDF/X output intent and identification (#263) in a new XMP packet; the source XMP (dates, other schemas) is not copied." [[feature]] id = "organize.extract-separate-files" @@ -2818,8 +2818,8 @@ milestone = "M4" status = "shipped" commands = ["page.split"] tools = ["doc_split"] -evidence = ["crates/ui-egui/tests/editing.rs::split_dialog_writes_one_file_per_part", "crates/organize/src/tests.rs::split_ranges_cover_every_page_once", "crates/organize/src/tests.rs::split_produces_standalone_documents_with_metadata"] -notes = "" +evidence = ["crates/ui-egui/tests/editing.rs::split_dialog_writes_one_file_per_part", "crates/organize/src/tests.rs::split_ranges_cover_every_page_once", "crates/organize/src/tests.rs::split_produces_standalone_documents_with_metadata", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"] +notes = "Each part carries the PDF/X output intent and identification (#263), as Extract does." [[feature]] id = "organize.split-before-pages" @@ -2890,8 +2890,8 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/editing.rs::a_folder_adds_its_pdfs_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/automation/tests/automation.rs::combine_extract_and_split", "crates/automation/tests/automation.rs::combine_opens_protected_files_with_their_passwords"] -notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step." +evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/editing.rs::a_folder_adds_its_pdfs_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/automation/tests/automation.rs::combine_extract_and_split", "crates/automation/tests/automation.rs::combine_opens_protected_files_with_their_passwords", "crates/organize/src/tests.rs::combine_keeps_pdfx_when_every_source_agrees", "crates/organize/src/tests.rs::combine_claims_no_pdfx_when_the_sources_disagree"] +notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed." [[feature]] id = "organize.combine-bookmarks" From 130b7ea06c334137900ae4912f825b03846a4ecb Mon Sep 17 00:00:00 2001 From: jcl-bit <336218001+jcl-bit@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:04:48 -0700 Subject: [PATCH 006/149] Security: print jobs, new digital IDs and the --control file no longer write through planted links (#338) * Security: pipe print jobs to lp instead of a shared temp folder On macOS, Linux and FreeBSD, Print wrote each job to /pdfcraft-print-/job-