diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 746c19ab82..511904b3ea 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1328,13 +1328,21 @@ impl Automation { let dir = self.resolve(a.str("out_dir")?, true)?; std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; let mut files = Vec::new(); + let mut used = std::collections::HashSet::new(); for (i, (first, last, bytes)) in parts.iter().enumerate() { let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); let file = match titles.iter().find(|t| t.0 + 1 == *first) { Some((_, t)) => format!("{stem}-{}.pdf", safe(t)), None => format!("{stem}-part{}.pdf", i + 1), }; - let path = child(&dir, &file); + // Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively. + let mut unique = file.clone(); + let mut n = 1; + while !used.insert(unique.to_lowercase()) { + n += 1; + unique = format!("{}-{n}.pdf", file.trim_end_matches(".pdf")); + } + let path = child(&dir, &unique); write_atomic(&path, bytes)?; files.push(json!({ "path": path.to_string_lossy(), "first_page": first, "last_page": last })); } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 16b820b80b..6a89e742fe 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -215,7 +215,7 @@ pub fn tools() -> Vec { }), &["paths"], )), - t("doc_split", "Split a document", "Split into several files written to out_dir: every N pages, before given pages, at top-level bookmarks (bookmarks: true; files named after them), or by file size (max_mb). Files are -partK.pdf.") + t("doc_split", "Split a document", "Split into several files written to out_dir: every N pages, before given pages, at top-level bookmarks (bookmarks: true; files named after them, repeated titles get -2, -3), or by file size (max_mb). Files are -partK.pdf.") .cmd("page.split") .with(schema( json!({ "doc": doc(), "every": { "type": "integer", "minimum": 1 }, "before": pages("that start a new part"), "bookmarks": { "type": "boolean" }, "max_mb": { "type": "number", "exclusiveMinimum": 0 }, "out_dir": { "type": "string" } }), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index c509668474..40e894ff0b 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2334,6 +2334,27 @@ fn stamps_through_tools() { assert!(matches!(a.call("stamp_custom", &json!({ "doc": doc, "page": 1, "path": "nope.png", "at": [1, 1] })), Err(ToolError::Failed(_)))); } +#[test] +fn bookmark_split_with_equal_titles_keeps_every_part() { + let dir = workdir("organize_dup_titles"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Same", "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Same", "page": 2 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "same", "page": 3 })); + let s = ok(&mut a, "doc_split", json!({ "doc": doc, "bookmarks": true, "out_dir": "parts" })); + let files: Vec = s["files"] + .as_array() + .unwrap() + .iter() + .map(|f| std::path::Path::new(f["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!(files, ["a-Same.pdf", "a-Same-2.pdf", "a-same-3.pdf"]); + for f in &files { + assert!(dir.join("parts").join(f).exists(), "{f} missing"); + } +} + #[test] fn organizing_with_filters_bookmark_splits_and_extract_options() { let dir = workdir("organize2"); diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 5cdd66e6f1..a7854f8fde 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -597,6 +597,7 @@ impl PdfCraftApp { } }; let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); + let mut used = std::collections::HashSet::new(); let named: Vec<(String, Arc>)> = parts .into_iter() .map(|(a, b, bytes)| { @@ -607,7 +608,14 @@ impl PdfCraftApp { None if a == b => format!("{stem} (page {a}).pdf"), None => format!("{stem} (pages {a}-{b}).pdf"), }; - (name, bytes) + // Equal titles (or titles that sanitize alike) get (2), (3), ... so no part overwrites another; compared case-insensitively. + let mut unique = name.clone(); + let mut n = 1; + while !used.insert(unique.to_lowercase()) { + n += 1; + unique = format!("{} ({n}).pdf", name.trim_end_matches(".pdf")); + } + (unique, bytes) }) .collect(); self.write_files(&named, "Choose a folder for the split files"); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 2d080a4295..c9ffcbd145 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1380,6 +1380,28 @@ fn split_dialog_writes_one_file_per_part() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn split_at_bookmarks_with_equal_titles_keeps_every_part() { + let dir = temp_path("split-dup-titles"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut h = organize(3); + h.state_mut().export_dir_override = Some(dir.to_string_lossy().into_owned()); + for (index, title) in [(0, "Same"), (1, "Same"), (2, "same")] { + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index, title: title.into(), page: index }); + } + h.state_mut().split_draft.mode = pdfcraft_ui_egui::SplitMode::Bookmarks; + h.state_mut().run_command("page.split"); + h.run_steps(3); + h.get_by_label_contains("Creates 3 files from 3 pages"); + h.get_by_label("Split").click(); + h.run_steps(3); + let mut names: Vec = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + assert_eq!(names, ["doc - Same (2).pdf", "doc - Same.pdf", "doc - same (3).pdf"]); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn split_before_selected_pages() { let dir = temp_path("split-sel");