mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 13:08:48 -06:00
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:
co-authored by
Claude Opus 5.5
parent
750fb9a337
commit
79fc2e9c71
@@ -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 })?
|
||||
|
||||
@@ -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"])),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)?)
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user