mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 13:37:35 -06:00
M4: reverse page order (organize.reverse-pages) (#1720)
Organize pages ▸ More ▸ Reverse pages (and the command palette) reverses the selected pages among their own positions, or the whole document when fewer than two pages are selected. It is one undoable step, needs the assemble permission like the other page edits, and is available headless as the page_reverse automation tool (pages optional, default all). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bd0b3fdb65
commit
eeadc79d15
@@ -283,6 +283,13 @@ impl Automation {
|
||||
let to = self.position(&a, "to")?;
|
||||
self.apply(&a, Edit::MovePages { pages, to })?
|
||||
}
|
||||
"page_reverse" => {
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(&a, "pages")?,
|
||||
None => (0..self.doc(&a)?.info.pages.len()).collect(),
|
||||
};
|
||||
self.apply(&a, Edit::ReversePages { pages })?
|
||||
}
|
||||
"page_insert_blank" => self.insert_blank(&a)?,
|
||||
"page_insert_file" => self.insert_file(&a)?,
|
||||
"page_extract" => self.page_extract(&a)?,
|
||||
|
||||
@@ -224,6 +224,9 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "pages": pages("to move"), "to": { "type": "integer", "minimum": 1 } }),
|
||||
&["doc", "pages", "to"],
|
||||
)),
|
||||
t("page_reverse", "Reverse pages", "Reverse the order of pages among the positions they occupy: the listed pages (at least two), or the whole document when pages is omitted. Undoable.")
|
||||
.cmd("page.reverse")
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("to reverse (default: all)") }), &["doc"])),
|
||||
t("page_insert_blank", "Insert a blank page", "Insert a blank page so it becomes page `at`. Size defaults to the neighbouring page. Undoable.")
|
||||
.cmd("page.insert_blank")
|
||||
.with(schema(
|
||||
|
||||
@@ -2082,6 +2082,21 @@ fn orphan_form_fields_through_tools() {
|
||||
assert_ne!(v, "Filled by an agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversing_pages_through_tools() {
|
||||
let dir = workdir("reverse");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "page_reverse", json!({ "doc": doc }));
|
||||
assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"], "no pages reverses the whole document");
|
||||
ok(&mut a, "page_reverse", json!({ "doc": doc, "pages": [1, 2] }));
|
||||
assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 3", "Page 1"], "only the listed pages swap");
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"]);
|
||||
assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [4] })), Err(ToolError::InvalidArgs(_))));
|
||||
assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [2] })), Err(ToolError::Failed(_))), "one page has nothing to reverse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicating_and_cropping_through_tools() {
|
||||
let dir = workdir("boxes");
|
||||
|
||||
@@ -206,6 +206,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready),
|
||||
item("Crop pages", "crop", "page.crop", Ready),
|
||||
item("Duplicate pages", "copy-plus", "page.duplicate", Ready),
|
||||
item("Reverse pages", "arrow-up-down", "page.reverse", Ready),
|
||||
item("Page labels", "tag", "page.number", Ready),
|
||||
item("Page transitions", "presentation", "page.transitions", Planned("M4")),
|
||||
],
|
||||
|
||||
@@ -362,6 +362,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("page.insert_blank", "Insert blank page", PAGES, None, Assembly, "file-plus"),
|
||||
c("page.rotate_dialog", "Rotate pages…", PAGES, None, Assembly, "rotate-cw"),
|
||||
c("page.duplicate", "Duplicate pages", PAGES, None, Assembly, "copy-plus"),
|
||||
c("page.reverse", "Reverse pages", PAGES, None, Assembly, "arrow-up-down"),
|
||||
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"),
|
||||
|
||||
@@ -1267,6 +1267,10 @@ pub enum Edit {
|
||||
pages: Vec<usize>,
|
||||
to: usize,
|
||||
},
|
||||
/// `pages` reversed among the positions they occupy.
|
||||
ReversePages {
|
||||
pages: Vec<usize>,
|
||||
},
|
||||
InsertBlankPage {
|
||||
at: usize,
|
||||
width: f64,
|
||||
@@ -1797,6 +1801,7 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Delete pages",
|
||||
"Move page",
|
||||
"Move pages",
|
||||
"Reverse pages",
|
||||
"Insert blank page",
|
||||
"Duplicate page",
|
||||
"Duplicate pages",
|
||||
@@ -1936,6 +1941,7 @@ impl Edit {
|
||||
Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()),
|
||||
Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()),
|
||||
Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()),
|
||||
Edit::ReversePages { .. } => Label::of("Reverse pages"),
|
||||
Edit::InsertBlankPage { .. } => Label::of("Insert blank page"),
|
||||
Edit::DuplicatePages { pages } => plural("Duplicate page", "Duplicate pages", pages.len()),
|
||||
Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()),
|
||||
@@ -2108,6 +2114,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
|
||||
Edit::RotatePages { .. }
|
||||
| Edit::DeletePages { .. }
|
||||
| Edit::MovePages { .. }
|
||||
| Edit::ReversePages { .. }
|
||||
| Edit::InsertBlankPage { .. }
|
||||
| Edit::DuplicatePages { .. }
|
||||
| Edit::ReplacePages { .. }
|
||||
@@ -2318,6 +2325,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?,
|
||||
Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?,
|
||||
Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?,
|
||||
Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?,
|
||||
Edit::InsertBlankPage { at, width, height } => {
|
||||
pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?;
|
||||
}
|
||||
|
||||
@@ -472,6 +472,26 @@ pub fn move_pages(doc: &mut Document, indices: &[usize], to: usize) -> Result<()
|
||||
rebuild(doc, &rest)
|
||||
}
|
||||
|
||||
/// Reverse the order of the pages at `indices` among the positions they occupy; every other
|
||||
/// page stays where it is. Fewer than two distinct pages is refused, since nothing would change.
|
||||
pub fn reverse_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> {
|
||||
let mut all = walk(doc)?;
|
||||
check(indices, all.len())?;
|
||||
let mut slots = indices.to_vec();
|
||||
slots.sort_unstable();
|
||||
slots.dedup();
|
||||
if slots.len() < 2 {
|
||||
return Err(OrganizeError::Invalid("reversing needs at least two pages".into()));
|
||||
}
|
||||
let pages: Vec<(ObjRef, Dict)> = slots.iter().filter_map(|i| all.get(*i).cloned()).collect();
|
||||
for (slot, page) in slots.iter().zip(pages.into_iter().rev()) {
|
||||
if let Some(entry) = all.get_mut(*slot) {
|
||||
*entry = page;
|
||||
}
|
||||
}
|
||||
rebuild(doc, &all)
|
||||
}
|
||||
|
||||
/// Duplicate pages: copies of `indices` (in order) are inserted after the last of them, sharing
|
||||
/// fonts and images with the originals.
|
||||
pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> {
|
||||
|
||||
@@ -244,6 +244,25 @@ fn move_pages_reorders() {
|
||||
assert_eq!(labels(&doc), ["Page 2", "Page 3", "Page 1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_pages_reorders() {
|
||||
let mut doc = open(fixture());
|
||||
reverse_pages(&mut doc, &[0, 1, 2]).unwrap();
|
||||
assert_eq!(labels(&doc), ["Page 3", "Page 2", "Page 1"]);
|
||||
// Unsorted, repeated indices name the same slots; page 1's slot stays put.
|
||||
reverse_pages(&mut doc, &[2, 1, 2]).unwrap();
|
||||
let doc = save_and_reopen(&doc);
|
||||
assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cannot_reverse_missing_or_fewer_than_two_pages() {
|
||||
let mut doc = open(fixture());
|
||||
assert_eq!(reverse_pages(&mut doc, &[0, 3]), Err(OrganizeError::NoSuchPage(3)));
|
||||
assert!(matches!(reverse_pages(&mut doc, &[1, 1]), Err(OrganizeError::Invalid(_))));
|
||||
assert!(!doc.is_modified(), "failed operations leave the document untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_blank_page() {
|
||||
let mut doc = open(fixture());
|
||||
|
||||
@@ -629,6 +629,12 @@ impl PdfCraftApp {
|
||||
"page.duplicate" => {
|
||||
self.apply_edit(Edit::DuplicatePages { pages: targets });
|
||||
}
|
||||
// Two or more selected pages reverse among themselves; otherwise the whole document.
|
||||
"page.reverse" => {
|
||||
let all = active.and_then(|i| self.views.get(i)).map_or(0, |v| v.page_count());
|
||||
let pages = if targets.len() >= 2 { targets } else { (0..all).collect() };
|
||||
self.apply_edit(Edit::ReversePages { pages });
|
||||
}
|
||||
"ocr.enhance_camera" => {
|
||||
// The corners start on the document's edges when they can be found, else
|
||||
// inset on the current page (camera_ui::page_input).
|
||||
|
||||
@@ -351,6 +351,21 @@ fn shift_click_selects_a_range_and_moves_it() {
|
||||
assert_eq!(page_texts(h.state()), ["Page 3", "Page 4", "Page 1", "Page 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_pages_reverses_the_selection_or_else_the_whole_document() {
|
||||
let mut h = organize(4);
|
||||
assert!(h.state_mut().execute("page.reverse"));
|
||||
h.run_steps(3);
|
||||
assert_eq!(page_texts(h.state()), ["Page 4", "Page 3", "Page 2", "Page 1"]);
|
||||
h.get_by_label("Page 1").click();
|
||||
h.run_steps(1);
|
||||
h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND);
|
||||
h.run_steps(2);
|
||||
assert!(h.state_mut().execute("page.reverse"));
|
||||
h.run_steps(3);
|
||||
assert_eq!(page_texts(h.state()), ["Page 2", "Page 3", "Page 4", "Page 1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_click_toggles_and_rotation_applies_to_selection() {
|
||||
let mut h = organize(3);
|
||||
|
||||
@@ -2833,11 +2833,11 @@ title = "Reverse page order"
|
||||
area = "D"
|
||||
tier = "P2"
|
||||
milestone = "M4"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["page.reverse"]
|
||||
tools = ["page_reverse"]
|
||||
evidence = ["crates/organize/src/tests.rs::reverse_pages_reorders", "crates/organize/src/tests.rs::cannot_reverse_missing_or_fewer_than_two_pages", "crates/automation/tests/automation.rs::reversing_pages_through_tools", "crates/ui-egui/tests/editing.rs::reverse_pages_reverses_the_selection_or_else_the_whole_document"]
|
||||
notes = "Organize Pages ▸ More ▸ Reverse pages, or the command palette: two or more selected pages swap among their own positions, otherwise the whole document reverses. One undo step. Not yet: an entry in the page thumbnails' right-click menu"
|
||||
|
||||
[[feature]]
|
||||
id = "organize.cut-copy-paste-pages"
|
||||
|
||||
Reference in New Issue
Block a user