From 79fc2e9c71e27ad1a0b98c7d3728d19670a24d89 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 23:46:34 -0400 Subject: [PATCH] M4.4: Replace pages (Acrobat semantics: page content, resources and boxes replaced; links, comments, fields and bookmarks kept): organize replace_pages, engine ReplacePages, file picker + Replace Pages dialog, page.replace command, page_replace tool (51 tools) Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/lib.rs | 11 +++++ crates/automation/src/tools.rs | 7 ++++ crates/automation/tests/automation.rs | 13 ++++++ crates/engine/src/catalog.rs | 2 +- crates/engine/src/commands.rs | 1 + crates/engine/src/lib.rs | 19 +++++++++ crates/organize/src/lib.rs | 34 +++++++++++++++ crates/organize/src/tests.rs | 23 +++++++++++ crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 59 +++++++++++++++++++++++++++ crates/ui-egui/src/files.rs | 42 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 5 +++ crates/ui-egui/tests/commands.rs | 2 +- crates/ui-egui/tests/editing.rs | 19 +++++++++ parity/acrobat-features.toml | 10 ++--- 15 files changed, 241 insertions(+), 7 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 87a0714ca..93abcdf18 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -211,6 +211,17 @@ impl Automation { self.apply(&a, Edit::SetBookmarkPage { path, page })? } "doc_protect" => self.doc_protect(&a)?, + "page_replace" => { + let pages = self.pages(&a, "pages")?; + let path = self.resolve(a.str("path")?, false)?; + let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?); + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let src_pages = match a.opt_ints("from_pages")? { + Some(p) => one_based(&p)?, + None => (0..pages.len()).collect(), + }; + self.apply(&a, Edit::ReplacePages { pages, name, bytes, src_pages })? + } "page_duplicate" => { let pages = self.pages(&a, "pages")?; self.apply(&a, Edit::DuplicatePages { pages })? diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 21fbc555c..0640b22e9 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -34,6 +34,10 @@ fn path(what: &str) -> Value { json!({ "type": "array", "items": { "type": "integer", "minimum": 1 }, "description": format!("{what}: 1-based positions from the top level, e.g. [2, 1] = the first child of the second bookmark.") }) } +fn path_arg() -> Value { + json!({ "type": "string", "description": "A file path (relative to --root when set)." }) +} + fn point() -> Value { json!({ "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "[x, y] in points from the top-left of the displayed page." }) } @@ -285,6 +289,9 @@ pub fn tools() -> Vec { .cmd("protect.remove") .destructive() .with(schema(json!({ "doc": doc() }), &["doc"])), + t("page_replace", "Replace pages", "Replace the content of pages with pages of another PDF (same count). Links, comments, form fields and bookmarks on the original pages stay, as in Acrobat. Undoable.") + .cmd("page.replace") + .with(schema(json!({ "doc": doc(), "pages": pages("to replace"), "path": path_arg(), "from_pages": pages("of the other file, in order (default: its first pages)") }), &["doc", "pages", "path"])), t("page_duplicate", "Duplicate pages", "Insert copies of pages after the last of them (fonts and images are shared, not copied). Undoable.") .cmd("page.duplicate") .with(schema(json!({ "doc": doc(), "pages": pages("to duplicate") }), &["doc", "pages"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index da0853cd7..ebaa59469 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -574,3 +574,16 @@ fn flattening_through_tools() { assert!(page_text(&mut a, doc)[0].contains("Approved"), "the text box is now page text"); assert!(matches!(a.call("doc_flatten", &json!({ "doc": doc, "comments": false, "fields": false })), Err(ToolError::InvalidArgs(_)))); } + +#[test] +fn replacing_pages_through_tools() { + let dir = workdir("replace"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_replace", json!({ "doc": doc, "pages": [2, 3], "path": "b.pdf", "from_pages": [2, 1] })); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 1"]); + assert!( + matches!(a.call("page_replace", &json!({ "doc": doc, "pages": [1, 2, 3], "path": "b.pdf" })), Err(ToolError::Failed(_))), + "b.pdf has only 2 pages" + ); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 2efd3a5ad..f31b90052 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -175,7 +175,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Delete", "trash-2", "page.delete", Ready), item("Extract", "file-output", "page.extract", Ready), item("Insert", "file-plus-2", "page.insert", Ready), - item("Replace", "replace", "page.replace", Planned("M4")), + item("Replace", "replace", "page.replace", Ready), item("Split", "scissors", "page.split", Ready), ], }, diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index b28751181..9b7c60b3c 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -206,6 +206,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.crop", "Crop pages", PAGES, None, Assembly, "crop"), c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"), c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"), + c("page.replace", "Replace pages…", PAGES, None, Assembly, "replace"), c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8dbae1187..f667c2277 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -378,6 +378,14 @@ pub enum Edit { DuplicatePages { pages: Vec, }, + /// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the + /// pages' links, comments and bookmarks stay. + ReplacePages { + pages: Vec, + name: String, + bytes: Arc>, + src_pages: Vec, + }, /// Set Page Boxes / Crop: set (or reset) one box on `pages`. SetPageBox { pages: Vec, @@ -535,6 +543,7 @@ impl Edit { Edit::MovePages { pages, .. } => plural("Move page", pages.len()), Edit::InsertBlankPage { .. } => "Insert blank page".into(), Edit::DuplicatePages { pages } => plural("Duplicate page", pages.len()), + Edit::ReplacePages { pages, .. } => plural("Replace page", pages.len()), Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", pages.len()), Edit::SetPageBox { .. } => "Set page boxes".into(), Edit::SetInfo { key, .. } => format!("Change {key}"), @@ -612,6 +621,7 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::MovePages { .. } | Edit::InsertBlankPage { .. } | Edit::DuplicatePages { .. } + | Edit::ReplacePages { .. } | Edit::SetPageBox { .. } | Edit::InsertPagesFrom { .. } // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). @@ -743,6 +753,10 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - } Edit::SetInfo { key, value } => printcraft_organize::set_info(doc, key, value)?, Edit::DuplicatePages { pages } => printcraft_organize::duplicate_pages(doc, pages)?, + Edit::ReplacePages { pages, name, bytes, src_pages } => { + let src = open_source(name, bytes)?; + printcraft_organize::replace_pages(doc, pages, &src, src_pages)?; + } Edit::SetPageBox { pages, which, spec } => printcraft_organize::set_page_box(doc, pages, *which, *spec)?, Edit::InsertPagesFrom { name, bytes, pages, at } => { let src = open_source(name, bytes)?; @@ -1201,6 +1215,11 @@ impl Session { Self::refresh(doc) } + /// The page count of another PDF (Replace Pages, Insert Pages dialogs). + pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { + Ok(printcraft_organize::page_count(&open_source(name, bytes)?)?) + } + /// A new blank document (Create ▸ Blank page). pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { self.write_new(&printcraft_create::blank(width, height, pages)?) diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index b43213085..69acc15ec 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -36,6 +36,8 @@ pub enum OrganizeError { #[error("{0}")] InvalidBox(String), #[error("{0}")] + Invalid(String), + #[error("{0}")] Cos(#[from] printcraft_cos::CosError), } @@ -184,6 +186,38 @@ pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), Orga import_pages(doc, &src, &order, last + 1).map(|_| ()) } +/// Replace Pages: the content of `targets` (in order) is replaced by that of `src_pages` of +/// `src`. As in Acrobat, only what the page shows changes (contents, resources and page boxes); +/// the original pages' links, comments, form widgets and the bookmarks pointing at them stay. +pub fn replace_pages(doc: &mut Document, targets: &[usize], src: &Document, src_pages: &[usize]) -> Result<(), OrganizeError> { + let n = page_count(doc)?; + check(targets, n)?; + if targets.len() != src_pages.len() || targets.is_empty() { + return Err(OrganizeError::Invalid(format!("{} pages can't replace {}", src_pages.len(), targets.len()))); + } + let imported = import_pages(doc, src, src_pages, n)?; + let all = walk(doc)?; + const SHOWN: [&[u8]; 8] = [b"Contents", b"Resources", b"MediaBox", b"CropBox", b"BleedBox", b"TrimBox", b"ArtBox", b"Rotate"]; + for (t, new) in targets.iter().zip(&imported) { + // The imported page with its inherited attributes resolved. + let (_, inherited) = all.iter().find(|(r, _)| r == new).cloned().unwrap_or((*new, Dict::new())); + let src_dict = doc.get(*new).as_dict().cloned().unwrap_or_default(); + let target = all[*t].0; + doc.update_dict(target, |d| { + for k in SHOWN { + match src_dict.get(k).or_else(|| inherited.get(k)) { + Some(v) => d.set(k.to_vec(), v.clone()), + None => { + d.remove(k); + } + } + } + })?; + } + // Drop the temporary copies (always the last pages now). + delete_pages(doc, &(n..n + imported.len()).collect::>()) +} + /// Insert a blank page of `width × height` points at position `at` (0 = before the first page). pub fn insert_blank_page(doc: &mut Document, at: usize, width: f64, height: f64) -> Result { let mut all = walk(doc)?; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index d0e08c5d4..c8ab36124 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -612,3 +612,26 @@ fn duplicate_pages_inserts_copies_after_the_last() { let doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 1", "Page 2", "Page 3"]); } + +#[test] +fn replace_pages_swaps_content_but_keeps_annotations() { + // Page 2 of the target gets an annotation; replacing its content keeps it. + let mut doc = open(fixture()); + let p2 = pages(&doc).unwrap()[1].obj; + let annot = doc.add(Object::Dict({ + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Square")); + d.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + d + })); + doc.update_dict(p2, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(annot)]))).unwrap(); + let src = open(fixture()); + replace_pages(&mut doc, &[1], &src, &[2]).unwrap(); + let doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); + assert_eq!(labels(&doc), ["Page 1", "Page 3", "Page 3"]); + let p2 = doc.get(pages(&doc).unwrap()[1].obj); + assert!(p2.as_dict().unwrap().contains(b"Annots"), "the original page's annotations stay"); + let mut doc = doc; + assert!(replace_pages(&mut doc, &[0, 1], &src, &[0]).is_err(), "counts must match"); +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index a08e48192..923fcd2c2 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -178,6 +178,7 @@ impl PrintCraftApp { } } "page.insert" => self.insert_from_file_dialog(), + "page.replace" => self.replace_pages_dialog(), "edit.header_footer" | "edit.watermark" | "edit.background" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 76648ca9d..707a471c4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -35,6 +35,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { let mut marks_now = false; let mut export_now = false; let mut props_now = false; + let mut replace_now = false; let t = Tokens::get(ctx); let mut close = false; let mut next = dialog; @@ -226,6 +227,55 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { split_ready = Some(by); } } + Dialog::ReplacePages => { + let count = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.info.pages.len()).unwrap_or(1).max(1); + let Some(d) = app.replace_draft.as_mut() else { + close = true; + return; + }; + ui.label(egui::RichText::new("Replace Pages").font(theme::semibold(18.0))); + ui.add_space(8.0); + d.to = d.to.clamp(1, count); + d.from = d.from.clamp(1, d.to); + let n = d.to - d.from + 1; + ui.horizontal(|ui| { + ui.label("Original: replace pages"); + ui.add(egui::DragValue::new(&mut d.from).range(1..=count)); + ui.label("to"); + ui.add(egui::DragValue::new(&mut d.to).range(1..=count)); + ui.label(egui::RichText::new(format!("of {count}")).color(t.text_muted)); + }); + let max_start = d.src_pages.saturating_sub(n) + 1; + d.src_from = d.src_from.clamp(1, max_start.max(1)); + ui.horizontal(|ui| { + ui.label(format!("Replacement: pages of {}", d.name)); + ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))); + ui.label(format!("to {}", d.src_from + n - 1)); + ui.label(egui::RichText::new(format!("of {}", d.src_pages)).color(t.text_muted)); + }); + let fits = n <= d.src_pages; + ui.add_space(6.0); + ui.label( + egui::RichText::new(if fits { + "Only the page content changes: links, comments, form fields and bookmarks on the original pages stay." + } else { + "The replacement file doesn't have that many pages." + }) + .small() + .color(t.text_faint), + ); + ui.add_space(10.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add_enabled_ui(fits, |ui| widgets::pill_button(ui, "OK", true)).inner.clicked() { + replace_now = true; + close = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + close = true; + } + }); + return; + } Dialog::CommentProps => { let (apply, cancel) = crate::comment_props::body(ui, app, &t); props_now = apply; @@ -465,6 +515,15 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { app.discard_recovered(&keys); } } + if replace_now && let Some(d) = app.replace_draft.take() { + let n = d.to - d.from + 1; + app.apply_edit(Edit::ReplacePages { + pages: (d.from - 1..d.to).collect(), + name: d.name.clone(), + bytes: d.bytes.clone(), + src_pages: (d.src_from - 1..d.src_from - 1 + n).collect(), + }); + } if props_now && let Some(d) = app.comment_props.take() { let mut edits = crate::comment_props::edits(&d); match edits.len() { diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 97745d087..3d801155a 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -14,6 +14,18 @@ use crate::PrintCraftApp; pub enum FilePurpose { Combine, InsertPages, + ReplacePages, +} + +/// The Replace Pages dialog: the chosen file and the ranges (1-based, inclusive). +#[derive(Clone, Debug, PartialEq)] +pub struct ReplaceDraft { + pub name: String, + pub bytes: Arc>, + pub src_pages: usize, + pub from: usize, + pub to: usize, + pub src_from: usize, } /// Files picked asynchronously (web), waiting to be used: (purpose, [(name, bytes)]). @@ -99,6 +111,11 @@ impl PrintCraftApp { self.insert_pages_from(&name, bytes); } } + FilePurpose::ReplacePages => { + if let Some((name, bytes)) = files.into_iter().next() { + self.start_replace(name, bytes); + } + } } } @@ -115,6 +132,31 @@ impl PrintCraftApp { } } + pub fn replace_pages_dialog(&mut self) { + if self.active.is_none() { + self.notify("Open a document first"); + return; + } + self.pick_files(FilePurpose::ReplacePages, false); + } + + /// Open the Replace Pages dialog for `bytes`, replacing the selection (or the current page). + pub fn start_replace(&mut self, name: String, bytes: Vec) { + let Some(i) = self.active else { return }; + let bytes = Arc::new(bytes); + let src_pages = match self.session.page_count_of(&name, &bytes) { + Ok(n) => n, + Err(e) => { + self.notify(format!("Couldn't use {name}: {e}")); + return; + } + }; + let targets = self.views[i].target_pages(); + let (from, to) = (targets.first().map_or(1, |p| p + 1), targets.last().map_or(1, |p| p + 1)); + self.replace_draft = Some(ReplaceDraft { name, bytes, src_pages, from, to, src_from: 1 }); + self.dialog = Some(crate::Dialog::ReplacePages); + } + /// Insert all pages of a PDF after the organize selection (or the current page). pub fn insert_pages_from(&mut self, name: &str, bytes: Vec) { let Some(i) = self.active else { return }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f06551690..bc56d9d99 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -105,6 +105,8 @@ pub enum Dialog { Signature, /// Comment Properties. CommentProps, + /// Replace Pages (after choosing the file). + ReplacePages, /// Documents from a session that ended unexpectedly. Recovery, } @@ -208,6 +210,8 @@ pub struct PrintCraftApp { pub signature_draft: Vec>, /// The Comment Properties dialog's state. pub comment_props: Option, + /// The Replace Pages dialog's state. + pub replace_draft: Option, /// The last web link the app asked the system to open (tests and automation). pub last_opened_url: Option, } @@ -278,6 +282,7 @@ impl PrintCraftApp { signature: None, signature_draft: Vec::new(), comment_props: None, + replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, } } diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 37a243766..b3075bf30 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -43,7 +43,7 @@ fn harness() -> Harness<'static, PrintCraftApp> { } /// Commands that open a native file picker can't run headless. -const PICKERS: &[&str] = &["file.open", "page.combine", "page.insert", "file.save_as", "create.file", "create.images"]; +const PICKERS: &[&str] = &["file.open", "page.combine", "page.insert", "file.save_as", "create.file", "create.images", "page.replace"]; #[test] fn every_registered_command_is_implemented() { diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index da8d03e02..e1e5625b6 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -465,3 +465,22 @@ fn restricted_documents_show_a_notice_and_block_page_changes() { h.run_steps(3); assert!(h.query_all_by_label("Not allowed").count() >= 4); } + +#[test] +fn replace_pages_dialog_swaps_page_content() { + let mut app = PrintCraftApp::new(); + app.open_bytes("doc.pdf", None, fixture(3)).unwrap(); + app.views[0].select_pages(&[1]); + app.start_replace("other.pdf".into(), fixture(5)); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| app); + h.run_steps(3); + egui_kittest::kittest::Queryable::get_by_label(&h, "Replace Pages"); + h.state_mut().replace_draft.as_mut().unwrap().src_from = 5; + h.run_steps(1); + egui_kittest::kittest::Queryable::get_by_label(&h, "OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.info.pages.len(), 3); + assert_eq!(doc.can_undo(), Some("Replace page")); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 149a48569..227bf72d9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2707,11 +2707,11 @@ title = "Replace pages with pages from another file" area = "D" tier = "P0" milestone = "M4" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["page.replace"] +tools = ["page_replace"] +evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/automation/tests/automation.rs::replacing_pages_through_tools"] +notes = "content, resources and boxes replaced; links, comments, fields and bookmarks kept" [[feature]] id = "organize.duplicate-pages"