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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
mlkonrad 2026-10-08 22:37:10 +03:00 committed by GitHub
parent f70c4caac9
commit 9140b44792
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 2500 additions and 151 deletions

View File

@ -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"

View File

@ -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:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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%).

View File

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

View File

@ -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<Option<String>> = 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<Option<&str>> = passwords.iter().map(Option::as_deref).collect();
let bytes = self.session.combine_unlocked(&sources, &passwords).map_err(failed)?;
self.deliver(a, "Combined", bytes)
}

View File

@ -185,12 +185,13 @@ pub fn tools() -> Vec<ToolDef> {
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,
}),

View File

@ -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]

View File

@ -1734,17 +1734,52 @@ pub fn guard<T>(f: impl FnOnce() -> T) -> Result<T, String> {
/// Parse another PDF to copy pages from.
fn open_source(name: &str, bytes: &Arc<Vec<u8>>) -> Result<pdfcraft_cos::Document, EditError> {
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<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, EditError> {
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<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, SourceProblem> {
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<Vec<u8>>, 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<Vec<u8>>, password: Option<&str>) -> Option<usize> {
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<Arc<Vec<u8>>, EditError> {
let docs = sources.iter().map(|(n, b, _)| open_source(n, b)).collect::<Result<Vec<_>, _>>()?;
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<Arc<Vec<u8>>, EditError> {
let docs = sources
.iter()
.enumerate()
.map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()))
.collect::<Result<Vec<_>, _>>()?;
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());

View File

@ -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);

View File

@ -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<usize>, 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<usize>, index: usize) -> egui::Response {
let font = theme::regular(13.0);
let label: String = if name.chars().count() > 28 { format!("{}…", name.chars().take(27).collect::<String>()) } 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());

File diff suppressed because it is too large Load Diff

View File

@ -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);
}

View File

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

View File

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

View File

@ -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.

View File

@ -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…

Can't render this file because it contains an unexpected character in line 6 and column 60.

View File

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

Can't render this file because it contains an unexpected character in line 6 and column 39.

View File

@ -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… ファイルを追加…

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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… 添加文件…

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -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… 新增檔案…

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -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<pdfcraft_engine::optimize::SpaceUse>,
/// Combine files: the files staged so far.
pub combine_draft: Vec<combine_ui::CombineFile>,
/// 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<create_ui::ImageImport>,
/// 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::<serde_json::Value>(json) else { return };
self.combine_columns = combine_ui::Columns::from_json(&v["combine_columns"]);
if let Ok(r) = serde_json::from_value::<Vec<RecentFile>>(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<u8>)> = 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),
});

View File

@ -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());

View File

@ -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::<Vec<_>>(), ["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<String> {
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<u8>)> = 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<bool> = 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<u8> {
protected_with(pdfcraft_cos::Algorithm::Aes256, user, owner, permissions)
}
fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec<u8> {
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,

View File

@ -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.

View File

@ -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"