diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 9b1a58c5..2159c8b7 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -4,7 +4,7 @@
Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
-## In this repository (222)
+## In this repository (223)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -230,6 +230,7 @@ Every asset PrintCraft includes, bundles or uses to build its published material
| `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PrintCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): a signature PrintCraft did not make |
+| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
## Compiled in through dependencies (6)
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index eaf613c5..eda26046 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -2783,3 +2783,14 @@ licence_file = "LICENSE-MIT"
kind = "document"
usage = "Signature validation tests (crates/sign/tests/pdf.rs): a signature PrintCraft did not make"
sha256 = "8b91c3b4e39c4a31717a0dd0685bd7d0468671df32aee5b9818d44dc72c2bc3c"
+
+[[asset]]
+path = "assets/icons/clipboard-paste.svg"
+title = "Lucide icon \"clipboard-paste\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg)"
+licence = "ISC"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+sha256 = "631d24bde604be36b2dcf3b6f86ddf6026879cdcd272c0dfe37ab0cc6b341fda"
diff --git a/assets/icons/clipboard-paste.svg b/assets/icons/clipboard-paste.svg
new file mode 100644
index 00000000..350c2ca5
--- /dev/null
+++ b/assets/icons/clipboard-paste.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs
index 1a8cb6a4..8c671f8a 100644
--- a/crates/engine/src/commands.rs
+++ b/crates/engine/src/commands.rs
@@ -147,6 +147,9 @@ const HELP: Option<&str> = Some("Help");
pub const COMMANDS: &[CommandSpec] = &[
c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"),
c("create.blank", "New blank PDF", FILE, None, Nothing, "file-plus-2"),
+ c("page.copy", "Copy pages", None, None, Document, "copy"),
+ c("page.cut", "Cut pages", None, None, Assembly, "scissors"),
+ c("page.paste", "Paste pages", None, None, Assembly, "clipboard-paste"),
c("create.file", "Create PDF from file…", FILE, None, Nothing, "file-input"),
c("create.images", "Create PDF from images…", FILE, None, Nothing, "image"),
c("create.clipboard", "Create PDF from clipboard", FILE, None, Nothing, "copy-plus"),
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index ae6bdaa8..8248415d 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -166,6 +166,12 @@ pub enum ViewAction {
InsertFromFile,
Extract,
Split,
+ /// Copy the selected pages (Cut also deletes them).
+ CopyPages {
+ cut: bool,
+ },
+ /// Paste copied pages after the selection.
+ PastePages,
}
impl DocView {
@@ -1875,6 +1881,17 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, ui: &mut
i.consume_key(Modifiers::NONE, Key::Escape),
)
});
+ // ⌘C / ⌘X / ⌘V copy, cut and paste pages (egui delivers them as clipboard events).
+ let (copy, cut, paste) = ui.input(|i| {
+ let any = |f: &dyn Fn(&egui::Event) -> bool| i.events.iter().any(f);
+ (any(&|e| matches!(e, egui::Event::Copy)), any(&|e| matches!(e, egui::Event::Cut)), any(&|e| matches!(e, egui::Event::Paste(_))))
+ });
+ if copy || cut {
+ view.pending_action = Some(ViewAction::CopyPages { cut });
+ }
+ if paste {
+ view.pending_action = Some(ViewAction::PastePages);
+ }
if del && targets.len() < n {
view.pending_edit = Some(Edit::DeletePages { pages: targets });
}
@@ -1967,6 +1984,25 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, pool: &RenderPool, editable
if resp.double_clicked() {
open_page = Some(i);
}
+ // Right-click: Cut, Copy, Paste (on the selection, or this page).
+ resp.context_menu(|ui| {
+ if !view.selected.contains(&i) {
+ view.selected = [i].into();
+ view.current = i;
+ }
+ if ui.add_enabled(editable, egui::Button::new("Cut")).clicked() {
+ view.pending_action = Some(ViewAction::CopyPages { cut: true });
+ ui.close();
+ }
+ if ui.button("Copy").clicked() {
+ view.pending_action = Some(ViewAction::CopyPages { cut: false });
+ ui.close();
+ }
+ if ui.add_enabled(editable, egui::Button::new("Paste after")).clicked() {
+ view.pending_action = Some(ViewAction::PastePages);
+ ui.close();
+ }
+ });
}
}
// While dragging: the gap the pages would go to, drawn as a bar.
diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs
index d5dd7c04..e348ddd8 100644
--- a/crates/ui-egui/src/commands.rs
+++ b/crates/ui-egui/src/commands.rs
@@ -308,6 +308,9 @@ impl PrintCraftApp {
}
"sign.panel" => self.right = Some(RightPanel::Signatures),
"optimize.advanced" => self.dialog = Some(Dialog::Optimize),
+ "page.copy" => self.copy_pages(false),
+ "page.cut" => self.copy_pages(true),
+ "page.paste" => self.paste_pages(),
"comment.hide_all" => {
if let Some(i) = active {
let id = self.views[i].id;
diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs
index 6512ac7d..ccdfca5c 100644
--- a/crates/ui-egui/src/editing.rs
+++ b/crates/ui-egui/src/editing.rs
@@ -101,10 +101,45 @@ impl PrintCraftApp {
Some(crate::canvas::ViewAction::InsertFromFile) => self.insert_from_file_dialog(),
Some(crate::canvas::ViewAction::Extract) => self.dialog = Some(crate::Dialog::Extract),
Some(crate::canvas::ViewAction::Split) => self.dialog = Some(crate::Dialog::Split),
+ Some(crate::canvas::ViewAction::CopyPages { cut }) => self.copy_pages(cut),
+ Some(crate::canvas::ViewAction::PastePages) => self.paste_pages(),
None => {}
}
}
+ /// Organize ▸ Copy / Cut: remember the selected pages (the document as it is now); Cut also
+ /// deletes them (one page always stays).
+ pub fn copy_pages(&mut self, cut: bool) {
+ let Some((i, id)) = self.active_ids() else { return };
+ let Some(doc) = self.session.get(id) else { return };
+ let pages = self.views[i].target_pages();
+ let n = doc.info.pages.len();
+ if cut && pages.len() >= n {
+ self.notify("A document needs at least one page: copy instead");
+ return;
+ }
+ self.page_clipboard = Some(crate::PageClip { name: doc.name.clone(), bytes: doc.bytes.clone(), pages: pages.clone() });
+ if cut {
+ self.apply_edit(Edit::DeletePages { pages: pages.clone() });
+ }
+ let what = if pages.len() == 1 { "1 page".to_string() } else { format!("{} pages", pages.len()) };
+ self.notify(format!("{} {what}", if cut { "Cut" } else { "Copied" }));
+ }
+
+ /// Organize ▸ Paste: insert the copied pages after the selection (or the current page).
+ pub fn paste_pages(&mut self) {
+ let Some(clip) = self.page_clipboard.clone() else {
+ self.notify("Copy or cut pages first");
+ return;
+ };
+ let Some(i) = self.active else { return };
+ let at = self.views[i].target_pages().into_iter().max().map_or(0, |p| p + 1);
+ let count = clip.pages.len();
+ if self.apply_edit(Edit::InsertPagesFrom { name: clip.name.clone(), bytes: clip.bytes.clone(), pages: Some(clip.pages.clone()), at }) {
+ self.views[i].select_pages(&(at..at + count).collect::>());
+ }
+ }
+
/// Save the active document. Returns `true` if it was written.
pub fn save_active(&mut self, target: SaveTarget) -> bool {
match self.active {
diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs
index baa3b360..39694d89 100644
--- a/crates/ui-egui/src/icon_data.rs
+++ b/crates/ui-egui/src/icon_data.rs
@@ -36,6 +36,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("circle-dot", include_bytes!("../../../assets/icons/circle-dot.svg")),
("circle-help", include_bytes!("../../../assets/icons/circle-help.svg")),
("circle-x", include_bytes!("../../../assets/icons/circle-x.svg")),
+ ("clipboard-paste", include_bytes!("../../../assets/icons/clipboard-paste.svg")),
("clock", include_bytes!("../../../assets/icons/clock.svg")),
("clock-3", include_bytes!("../../../assets/icons/clock-3.svg")),
("cloud", include_bytes!("../../../assets/icons/cloud.svg")),
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 183200b3..2a8e1792 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -173,6 +173,14 @@ pub enum PropsTab {
Advanced,
}
+/// Copied pages: their document's name and bytes (as it was when copied) and the pages.
+#[derive(Clone, Debug)]
+pub struct PageClip {
+ pub name: String,
+ pub bytes: std::sync::Arc>,
+ pub pages: Vec,
+}
+
/// Files delivered asynchronously: (name, bytes).
pub type Inbox = std::sync::Arc)>>>;
@@ -245,6 +253,8 @@ pub struct PrintCraftApp {
pub sig_expanded: Vec,
/// PDF Optimizer choices.
pub optimize_draft: OptimizeDraft,
+ /// Pages copied or cut in Organize Pages, ready to paste (into any document).
+ pub page_clipboard: Option,
/// Where autosaves go (`None`: autosave off, e.g. on the web and in tests).
pub recovery: Option,
/// Entries left by a previous session, offered in the Recovery dialog.
@@ -349,6 +359,7 @@ impl PrintCraftApp {
digital_ids: Vec::new(),
sig_expanded: Vec::new(),
optimize_draft: OptimizeDraft::default(),
+ page_clipboard: None,
recovery: None,
recoverable: Vec::new(),
recovery_keys: Default::default(),
diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs
index 026f79d1..490606e3 100644
--- a/crates/ui-egui/tests/editing.rs
+++ b/crates/ui-egui/tests/editing.rs
@@ -541,3 +541,29 @@ fn dragging_thumbnails_reorders_pages() {
let sel: Vec = h.state().views[0].selected.iter().copied().collect();
assert_eq!(sel, vec![2], "the moved page stays selected");
}
+
+#[test]
+fn copying_cutting_and_pasting_pages() {
+ let mut h = organize(4);
+ let before = page_texts(h.state());
+ h.state_mut().views[0].select_pages(&[0]);
+ assert!(h.state_mut().execute("page.copy"));
+ h.state_mut().views[0].select_pages(&[2]);
+ assert!(h.state_mut().execute("page.paste"));
+ h.run_steps(2);
+ let after = page_texts(h.state());
+ assert_eq!(after, vec![before[0].clone(), before[1].clone(), before[2].clone(), before[0].clone(), before[3].clone()], "pasted after page 3");
+ let sel: Vec = h.state().views[0].selected.iter().copied().collect();
+ assert_eq!(sel, vec![3], "the pasted page is selected");
+ // Cut page 2 and paste it at the end.
+ h.state_mut().views[0].select_pages(&[1]);
+ assert!(h.state_mut().execute("page.cut"));
+ h.state_mut().views[0].select_pages(&[3]);
+ assert!(h.state_mut().execute("page.paste"));
+ let after = page_texts(h.state());
+ assert_eq!(after, vec![before[0].clone(), before[2].clone(), before[0].clone(), before[3].clone(), before[1].clone()]);
+ // Every page can be copied but not cut.
+ h.state_mut().views[0].select_pages(&[0, 1, 2, 3, 4]);
+ h.state_mut().execute("page.cut");
+ assert_eq!(page_texts(h.state()).len(), 5);
+}
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 5aadc451..a148170f 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -2743,11 +2743,11 @@ title = "Cut, copy and paste pages"
area = "D"
tier = "P1"
milestone = "M4"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
+status = "shipped"
+commands = ["page.copy", "page.cut", "page.paste"]
+tools = ["page_insert_file"]
+evidence = ["crates/ui-egui/tests/editing.rs::copying_cutting_and_pasting_pages"]
+notes = "⌘C/⌘X/⌘V and the thumbnail menu in Organize Pages; paste works across documents"
[[feature]]
id = "organize.drag-pages-between-documents"