From c16c6b5716db5fef30d1e17840ed3f37419bdbfa Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 04:56:35 -0500 Subject: [PATCH 1/2] M4: cut, copy and paste bookmark subtrees (#3994) The Bookmarks panel's item menu gets Cut, Copy and Paste, and Bookmark options gets Paste bookmarks (to the end of the outline, which is how you paste into a document without one). The clipboard lives in the app (`PdfCraftApp::bookmark_clipboard`), like the Organize Pages clipboard, so it works across tabs and on the web build. - Copy reads the subtree with `outline_tree`; a copy cut at the listing depth says so. - Cut copies first and deletes only if the copy succeeded: one undo step. - Paste is one `ImportBookmarks` edit after the bookmark it was asked on, at its level. A bookmark whose page the document lacks keeps its place without a destination, and a notice counts them (plural forms through `trn`). Parity: new `organize.bookmark-clipboard` entry with what is missing (shortcuts, system clipboard, multi-select, page-ordered insertion). Test: cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents. Reverting copy, cut, paste, the paste's parent, the one-step undo or the notice each fails it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/editing.rs | 52 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/src/panels/bookmarks.rs | 16 ++++++ crates/ui-egui/src/panels/right.rs | 6 +++ crates/ui-egui/tests/it/bookmarks.rs | 72 ++++++++++++++++++++++++++ parity/acrobat-features.toml | 12 +++++ 7 files changed, 162 insertions(+) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 6ac8ea8783..43a1e5ce1d 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -260,6 +260,7 @@ impl PdfCraftApp { fonts_cjk: theme::CjkPreference::Default, control: None, bookmark_rename: None, + bookmark_clipboard: None, last_opened_url: None, community_card_closed: false, pending_link: None, diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 4440637a81..f2b20f39ed 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -977,6 +977,20 @@ impl PdfCraftApp { let at = parent[parent.len() - 1] + 1; Edit::MoveBookmark { from: path, to_parent: grand, index: at } } + A::Copy(path) => { + self.copy_bookmark(id, &path); + return; + } + A::Cut(path) => { + if !self.copy_bookmark(id, &path) { + return; + } + Edit::DeleteBookmark { path } + } + A::Paste { parent, index } => { + self.paste_bookmarks(id, parent, index); + return; + } A::FromStructure => { self.right = Some(crate::RightPanel::Bookmarks); Edit::BookmarksFromStructure @@ -991,6 +1005,44 @@ impl PdfCraftApp { } } +impl PdfCraftApp { + /// Put the bookmark at `path` and its subtree on the bookmark clipboard; `false` if it could + /// not be read (the reason is shown). + fn copy_bookmark(&mut self, id: pdfcraft_engine::DocId, path: &[usize]) -> bool { + match self.session.outline_tree(id, path) { + Some(Ok(tree)) => { + if tree.truncated { + self.notify_tr("Bookmarks nested too deep to list were left out of the copy."); + } + self.bookmark_clipboard = Some(tree.nodes); + true + } + Some(Err(e)) => { + self.notify_error(e); + false + } + None => false, + } + } + + /// Paste the bookmark clipboard as one undoable edit. Bookmarks whose page this document lacks + /// arrive without a destination, and the toast says how many. + fn paste_bookmarks(&mut self, id: pdfcraft_engine::DocId, parent: Vec, index: usize) { + let Some(bookmarks) = self.bookmark_clipboard.clone() else { return }; + let pages = self.session.get(id).map_or(0, |d| d.info.pages.len()); + let unmapped = pdfcraft_engine::outline_unmapped(&bookmarks, pages).len(); + if self.apply_edit(Edit::ImportBookmarks { parent, index, bookmarks, replace: false }) && unmapped > 0 { + let n = u64::try_from(unmapped).unwrap_or(u64::MAX); + self.notify(crate::i18n::trn( + crate::i18n::current(), + n, + "{n} pasted bookmark points past the last page of this document and has no destination.", + "{n} pasted bookmarks point past the last page of this document and have no destination.", + )); + } + } +} + fn bookmark_at<'a>(items: &'a [pdfcraft_render::OutlineItem], path: &[usize]) -> Option<&'a pdfcraft_render::OutlineItem> { let (first, rest) = path.split_first()?; let item = items.get(*first)?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 643d5e90ff..28382ea255 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -611,6 +611,9 @@ pub struct PdfCraftApp { control: Option, /// A bookmark being renamed in the Bookmarks panel: (path, text so far). pub bookmark_rename: Option<(Vec, String)>, + /// Bookmarks copied or cut in the Bookmarks panel, with their subtrees, ready to paste into + /// any document. Destinations are page indices. + pub bookmark_clipboard: Option>, /// Number pages dialog settings (1-based pages). pub number_draft: NumberDraft, pub(crate) word_count_draft: word_count_ui::Draft, diff --git a/crates/ui-egui/src/panels/bookmarks.rs b/crates/ui-egui/src/panels/bookmarks.rs index ccc45d4eb0..2b68b93158 100644 --- a/crates/ui-egui/src/panels/bookmarks.rs +++ b/crates/ui-egui/src/panels/bookmarks.rs @@ -19,6 +19,15 @@ pub enum BmAction { Indent(Vec), /// Move it out to follow its parent. Outdent(Vec), + /// Copy the bookmark and everything under it to the bookmark clipboard. + Copy(Vec), + /// Copy it, then delete it (one undo step: only the delete changes the document). + Cut(Vec), + /// Paste the bookmark clipboard under `parent` at `index` (past the end appends). + Paste { + parent: Vec, + index: usize, + }, /// Bookmarks from the tagged headings, under a new first "Untitled" bookmark. FromStructure, /// Open New bookmarks from text styles, to choose a style per bookmark level (#1693). @@ -55,6 +64,8 @@ pub(super) struct OutlineCtx<'a> { pub(super) action: &'a mut Option, pub(super) rename: &'a mut Option<(Vec, String)>, pub(super) editable: bool, + /// The bookmark clipboard holds something to paste. + pub(super) can_paste: bool, pub(super) current: usize, /// Expand all (`Some(usize::MAX)`), collapse all (`Some(0)`) or expand to a depth, this frame. pub(super) expand: Option, @@ -242,6 +253,11 @@ pub(super) fn outline_item( pick(ui, tl!("Indent"), i > 0, BmAction::Indent(path.to_vec())); pick(ui, tl!("Outdent"), depth > 0, BmAction::Outdent(path.to_vec())); ui.separator(); + pick(ui, tl!("Cut"), true, BmAction::Cut(path.to_vec())); + pick(ui, tl!("Copy"), true, BmAction::Copy(path.to_vec())); + let parent = path.get(..depth).unwrap_or_default().to_vec(); + pick(ui, tl!("Paste"), cx.can_paste, BmAction::Paste { parent, index: i.saturating_add(1) }); + ui.separator(); pick(ui, tl!("Delete"), true, BmAction::Delete(path.to_vec())); if let Some(a) = styled { *cx.action = Some(a); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 79769867d3..ae9fbb9266 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -24,6 +24,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut compare_action: Option = None; let mut bm_rename = app.bookmark_rename.clone(); let bm_editable = app.session.get(id).is_some_and(|d| d.allows_assembly() && d.read_only_reason.is_none()); + let bm_can_paste = app.bookmark_clipboard.is_some(); // Prepare a form is open: the Fields panel orders tabs. let preparing = app.is_preparing(); { @@ -111,6 +112,10 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { bm_action = Some(BmAction::FromTextStyles); ui.close(); } + if bm_editable && ui.add_enabled(bm_can_paste, egui::Button::new(tl!("Paste bookmarks"))).clicked() { + bm_action = Some(BmAction::Paste { parent: Vec::new(), index: usize::MAX }); + ui.close(); + } if !info.outline.is_empty() { for (levels, label) in [ (usize::MAX, tl!("Expand all bookmarks")), @@ -195,6 +200,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { action: &mut bm_action, rename: &mut bm_rename, editable: bm_editable, + can_paste: bm_can_paste, current: view.current, expand: bm_expand, matches: &bookmark_matches, diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index 1f07102a18..5251871401 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -459,3 +459,75 @@ trailer << /Root 1 0 R >> h.run_steps(3); assert!(!shown(&h, "Closed child B") && !shown(&h, "Open child B"), "search leaves the open state alone"); } + +/// The active document's outline, flattened like [`outline`]. +fn active_outline(h: &Harness<'static, PdfCraftApp>) -> Vec<(String, Option, usize)> { + let i = h.state().active.unwrap(); + let id = h.state().views[i].id; + fn flat(items: &[pdfcraft_render::OutlineItem], depth: usize, out: &mut Vec<(String, Option, usize)>) { + for i in items { + out.push((i.title.clone(), i.page, depth)); + flat(&i.children, depth + 1, out); + } + } + let mut out = Vec::new(); + flat(&h.state().session.get(id).unwrap().info.outline, 0, &mut out); + out +} + +const ONE_PAGE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents() { + let mut h = harness(); + add(&mut h, "Intro"); + h.state_mut().set_option("page", "3").unwrap(); + h.run_steps(3); + add(&mut h, "Results"); + menu(&mut h, "Results", "Indent"); + let tree = [("Intro".to_string(), Some(0), 0), ("Results".to_string(), Some(2), 1)]; + assert_eq!(outline(&h), tree); + + // Copy and paste after itself: the whole subtree, as one undo step. + menu(&mut h, "Intro", "Copy"); + menu(&mut h, "Intro", "Paste"); + assert_eq!(outline(&h), [tree.clone(), tree.clone()].concat()); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(outline(&h), tree, "a paste is one undo step"); + // Pasted on a child, it goes in after that child, at the child's level. + menu(&mut h, "Results", "Paste"); + let nested = [("Intro".to_string(), Some(0), 1), ("Results".to_string(), Some(2), 2)]; + assert_eq!(outline(&h), [tree.clone(), nested].concat()); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(outline(&h), tree); + + // Cut takes the subtree away in one step and keeps it to paste. + menu(&mut h, "Intro", "Cut"); + assert!(outline(&h).is_empty()); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(outline(&h), tree, "a cut is one undo step"); + + // Into a one-page document: Results' page 3 is not there, so it keeps its place without a + // destination rather than pointing at the wrong page, and the toast says so. + h.state_mut().open_bytes("one.pdf", None, ONE_PAGE.to_vec()).unwrap(); + h.run_steps(3); + h.get_by_label("Bookmark options").click(); + h.run_steps(2); + h.get_by_label("Paste bookmarks").click(); + h.run_steps(3); + assert_eq!(active_outline(&h), [("Intro".to_string(), Some(0), 0), ("Results".to_string(), None, 1)]); + let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("1 pasted bookmark points past the last page"), "{toast}"); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert!(active_outline(&h).is_empty()); + assert_eq!(outline(&h), tree, "the source is untouched"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 909cec18da..ac1ebdd20b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3433,6 +3433,18 @@ tools = ["bookmark_export", "bookmark_import", "bookmark_copy"] evidence = ["crates/organize/src/tests.rs::an_exported_outline_imports_into_another_document_unchanged", "crates/organize/src/tests.rs::a_page_the_target_lacks_keeps_its_bookmark_without_a_destination", "crates/organize/src/tests.rs::an_import_appends_inserts_or_replaces", "crates/organize/src/tests.rs::a_bad_node_anywhere_refuses_the_whole_import", "crates/automation/tests/it/automation.rs::bookmarks_export_import_and_copy_between_documents", "crates/automation/tests/it/automation.rs::a_bad_bookmark_tree_is_refused_whole_and_names_the_node"] notes = "#6018, headless only. The bookmark tree (or one bookmark's subtree) goes out as JSON with titles, nesting, open state, colour, bold/italic and destinations as page plus view in PDF user space, and comes back unchanged: into another document, under any bookmark, at any position, or in place of its outline, as one undoable step; bookmark_copy does the same between open documents by page number. A bookmark whose page the target lacks keeps its place without a destination and is reported, never dropped. Not yet: a panel command and preview, CSV/Markdown/pdftk formats, remapping by named destination (named destinations travel resolved to their page), actions other than go-to-page, structure links (/SE)" +[[feature]] +id = "organize.bookmark-clipboard" +title = "Cut, copy and paste bookmarks" +area = "D" +tier = "P2" +milestone = "M4" +status = "shipped" +commands = [] +tools = ["bookmark_copy", "bookmark_export", "bookmark_import", "bookmark_delete"] +evidence = ["crates/ui-egui/tests/it/bookmarks.rs::cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents", "crates/automation/tests/it/automation.rs::bookmarks_export_import_and_copy_between_documents"] +notes = "#3994 (the cut/copy/paste part). Cut, Copy and Paste in a bookmark's menu, and Paste bookmarks in Bookmark options, carry a bookmark with its whole subtree within a document or into another one, each as one undo step. A destination is a page index: a bookmark whose page the target lacks keeps its place without a destination and the toast counts them. Headless, bookmark_copy copies between open documents directly (there is no clipboard). Not yet: keyboard shortcuts in the panel, the system clipboard (the bookmarks stay inside the app), several bookmarks at once, copying out of a document that cannot be edited (the bookmark menu is hidden there), page-ordered insertion (the other half of #3994), actions other than go-to-page" + [[feature]] id = "organize.attachments-add" title = "Add file attachments" From bfa6db7bd39975fade5180404e5639dea47b9fa0 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Wed, 4 Nov 2026 05:53:41 -0500 Subject: [PATCH 2/2] M4: a cut too deep to copy leaves the bookmarks in place (#3994) A cut copies the subtree, then deletes it. When the copy is truncated (bookmarks nested past the listing depth), paste cannot bring back what was left out, so the cut now keeps the copy, deletes nothing and says why. Test: cutting_a_subtree_too_deep_to_copy_keeps_it (40-level synthetic outline); fails when the truncated copy is allowed to delete. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/ui-egui/src/editing.rs | 24 ++++++++++-------- crates/ui-egui/tests/it/bookmarks.rs | 37 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index f2b20f39ed..879025b01d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -981,12 +981,15 @@ impl PdfCraftApp { self.copy_bookmark(id, &path); return; } - A::Cut(path) => { - if !self.copy_bookmark(id, &path) { + A::Cut(path) => match self.copy_bookmark(id, &path) { + Some(false) => Edit::DeleteBookmark { path }, + // Paste could not bring back what the copy left out, so the bookmarks stay. + Some(true) => { + self.notify_tr("Bookmarks nested too deep to list could not be copied, so nothing was cut."); return; } - Edit::DeleteBookmark { path } - } + None => return, + }, A::Paste { parent, index } => { self.paste_bookmarks(id, parent, index); return; @@ -1006,22 +1009,23 @@ impl PdfCraftApp { } impl PdfCraftApp { - /// Put the bookmark at `path` and its subtree on the bookmark clipboard; `false` if it could - /// not be read (the reason is shown). - fn copy_bookmark(&mut self, id: pdfcraft_engine::DocId, path: &[usize]) -> bool { + /// Put the bookmark at `path` and its subtree on the bookmark clipboard. `Some(true)` when + /// bookmarks nested too deep to list were left out; `None` if it could not be read (the reason + /// is shown). + fn copy_bookmark(&mut self, id: pdfcraft_engine::DocId, path: &[usize]) -> Option { match self.session.outline_tree(id, path) { Some(Ok(tree)) => { if tree.truncated { self.notify_tr("Bookmarks nested too deep to list were left out of the copy."); } self.bookmark_clipboard = Some(tree.nodes); - true + Some(tree.truncated) } Some(Err(e)) => { self.notify_error(e); - false + None } - None => false, + None => None, } } diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index 5251871401..668227e8e7 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -531,3 +531,40 @@ fn cut_copy_and_paste_a_bookmark_subtree_within_and_between_documents() { assert!(active_outline(&h).is_empty()); assert_eq!(outline(&h), tree, "the source is untouched"); } + +/// A one-page document whose single bookmark "Top" heads a chain `depth` bookmarks deep. +fn deep_outline_pdf(depth: usize) -> Vec { + let mut pdf = String::from( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Outlines /First 10 0 R /Last 10 0 R /Count 1 >> endobj +", + ); + for k in 0..depth { + let id = 10 + k; + let parent = if k == 0 { 4 } else { id - 1 }; + let title = if k == 0 { "Top".to_string() } else { format!("L{k}") }; + let kids = if k + 1 < depth { format!("/First {0} 0 R /Last {0} 0 R /Count 1", id + 1) } else { String::new() }; + pdf.push_str(&format!("{id} 0 obj << /Title ({title}) /Parent {parent} 0 R {kids} /Dest [3 0 R /Fit] >> endobj\n")); + } + pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF"); + pdf.into_bytes() +} + +/// Cutting a subtree nested deeper than a copy can hold would lose what the copy left out, so +/// the cut copies and says why it deleted nothing. +#[test] +fn cutting_a_subtree_too_deep_to_copy_keeps_it() { + let mut h = harness(); + h.state_mut().open_bytes("deep.pdf", None, deep_outline_pdf(40)).unwrap(); + h.run_steps(3); + let before = active_outline(&h); + assert_eq!(before.first().map(|b| b.0.as_str()), Some("Top")); + menu(&mut h, "Top", "Cut"); + assert_eq!(active_outline(&h), before, "nothing was cut"); + let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("so nothing was cut"), "{toast}"); + assert!(h.state().bookmark_clipboard.is_some(), "the copy is still on the bookmark clipboard"); +}