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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 23:46:34 -04:00
co-authored by Claude Opus 5.5
parent 750fb9a337
commit 79fc2e9c71
15 changed files with 241 additions and 7 deletions
+11
View File
@@ -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 })?
+7
View File
@@ -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<ToolDef> {
.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"])),
+13
View File
@@ -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"
);
}
+1 -1
View File
@@ -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),
],
},
+1
View File
@@ -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"),
+19
View File
@@ -378,6 +378,14 @@ pub enum Edit {
DuplicatePages {
pages: Vec<usize>,
},
/// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the
/// pages' links, comments and bookmarks stay.
ReplacePages {
pages: Vec<usize>,
name: String,
bytes: Arc<Vec<u8>>,
src_pages: Vec<usize>,
},
/// Set Page Boxes / Crop: set (or reset) one box on `pages`.
SetPageBox {
pages: Vec<usize>,
@@ -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<Vec<u8>>) -> Result<usize, EditError> {
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<Arc<Vec<u8>>, EditError> {
self.write_new(&printcraft_create::blank(width, height, pages)?)
+34
View File
@@ -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::<Vec<_>>())
}
/// 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<ObjRef, OrganizeError> {
let mut all = walk(doc)?;
+23
View File
@@ -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");
}
+1
View File
@@ -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"
+59
View File
@@ -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() {
+42
View File
@@ -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<Vec<u8>>,
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<u8>) {
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<u8>) {
let Some(i) = self.active else { return };
+5
View File
@@ -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<Vec<[f32; 2]>>,
/// The Comment Properties dialog's state.
pub comment_props: Option<comment_props::PropsDraft>,
/// The Replace Pages dialog's state.
pub replace_draft: Option<files::ReplaceDraft>,
/// The last web link the app asked the system to open (tests and automation).
pub last_opened_url: Option<String>,
}
@@ -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 },
}
}
+1 -1
View File
@@ -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() {
+19
View File
@@ -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"));
}
+5 -5
View File
@@ -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"