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:
Enrico
2026-10-18 03:48:47 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent bd0b3fdb65
commit eeadc79d15
11 changed files with 100 additions and 5 deletions
+7
View File
@@ -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)?,
+3
View File
@@ -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(
+15
View File
@@ -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");
+1
View File
@@ -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")),
],
+1
View File
@@ -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"),
+8
View File
@@ -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)?;
}
+20
View File
@@ -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> {
+19
View File
@@ -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());
+6
View File
@@ -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).
+15
View File
@@ -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);
+5 -5
View File
@@ -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"