diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a5ebf3e38e..cd9a4163a2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3870,6 +3870,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some((p, i)) = app.views[index].comments.default_request.take() { app.make_comment_default(p, i); } + if let Some((p, i, open)) = app.views[index].comments.attachment_request.take() { + let doc = app.views[index].id; + app.comment_attachment_action(doc, p, i, open); + } if let Some((name, w)) = field_props { app.open_field_props(&name, w); } diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index b49a0dce25..68b1a72a0c 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -504,6 +504,9 @@ pub struct CommentView { pub props_request: Option<(usize, usize)>, /// Make Current Properties Default was asked for: (page, index). pub default_request: Option<(usize, usize)>, + /// A file attachment comment's file was asked for: (page, index, open it in a new tab + /// rather than save it). + pub attachment_request: Option<(usize, usize, bool)>, /// Replace Text: the struck-out text's quads while the replacement is typed. pub replace: Option>, /// Attach file: where the icon goes (page, top-left in user space), for the app to pick a file. @@ -1595,6 +1598,23 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool } } +/// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's +/// menus as in the Attachments panel: the comment is where the reader finds its file. Reading +/// the file changes nothing, so a read-only document offers them too. +pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, a: &Annotation) { + let source = pdfcraft_render::AttachmentSource::Annotation { page: a.page, index: a.index }; + let Some(file) = info.attachments.iter().find(|f| f.source == source) else { return }; + if ui.button(tl!("Save attachment…")).clicked() { + view.comments.attachment_request = Some((a.page, a.index, false)); + ui.close(); + } + if file.name.to_lowercase().ends_with(".pdf") && ui.button(tl!("Open in a new tab")).clicked() { + view.comments.attachment_request = Some((a.page, a.index, true)); + ui.close(); + } + ui.separator(); +} + /// Items of the canvas context menu for the selected comment (or the page). pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, allowed: bool) -> Option { let mut action = None; @@ -1602,6 +1622,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo match selected { Some(a) => { let (page, index) = (a.page, a.index); + attachment_items(ui, view, info, a); if ui.add_enabled(allowed, egui::Button::new(tl!("Edit text…"))).clicked() { view.comments.composer = Some(Composer { page, diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index c5fbbd0ce3..2ac1853f4e 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -255,7 +255,7 @@ pub(crate) fn show( continue; } let thread = replies_of(a); - if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) { + if let Some(e) = card(ui, t, a, &thread, info, view, prefs, allowed, nav) { edit = Some(e); } } @@ -268,6 +268,7 @@ fn card( t: &Tokens, a: &Annotation, thread: &[&Annotation], + info: &DocInfo, view: &mut DocView, prefs: &CommentPrefs, allowed: bool, @@ -447,6 +448,7 @@ fn card( .layout(Layout::top_down_justified(Align::Min)) .show(|ui| { ui.set_min_width(160.0); + crate::comments::attachment_items(ui, view, info, a); if ui.button(tl_ctx!("comment menu", "Edit")).clicked() { view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default())); ui.close(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e7df5d6706..e173b93d4a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1541,6 +1541,15 @@ impl PdfCraftApp { } } + /// [`Self::attachment_action`] for the file of the attachment comment at (page, index). + pub fn comment_attachment_action(&mut self, doc: DocId, page: usize, index: usize, open: bool) { + let source = pdfcraft_render::AttachmentSource::Annotation { page, index }; + // The menus offer the items only for a listed file, so a miss is a comment gone since. + if let Some(i) = self.session.get(doc).and_then(|d| d.info.attachments.iter().position(|a| a.source == source)) { + self.attachment_action(doc, i, open); + } + } + /// Whether the app draws the window's title bar, buttons and resize edges: the desktop leaves /// the frame to the app and Use system title bar is off. pub fn draws_window_frame(&self) -> bool { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index fae129b308..69600dac0e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -800,6 +800,9 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.default_request.take()) { app.make_comment_default(p, i); } + if let Some((p, i, open)) = app.views.get_mut(index).and_then(|v| v.comments.attachment_request.take()) { + app.comment_attachment_action(id, p, i, open); + } if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.props_request.take()) { app.open_comment_props(p, i); } diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index f4eb84359b..9a3716c3ee 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -1213,6 +1213,91 @@ fn attaching_a_file_as_a_comment() { assert_eq!(s.quick_tool, QuickTool::Select); } +/// One 300×200 page with a paperclip FileAttachment comment at (100, 100)–(120, 124) carrying +/// `inner.pdf`, and no /Contents: the card shows only "Attachment" (#2565). The cross-reference +/// table keeps the reader from mistaking the embedded PDF's objects for the outer ones. +fn attachment_comment_fixture() -> Vec { + let inner = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /FileAttachment /Rect [100 100 120 124] /Name /Paperclip /T (Reviewer) /M (D:20261014105200) /FS 5 0 R >>".into(), + "<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 6 0 R >> >>".into(), + format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n{inner}\nendstream", inner.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn attachment_harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("annexed.pdf", None, attachment_comment_fixture()).expect("opens"); + app.set_option("left", "closed").unwrap(); + setup(&mut app); + app + }); + h.run_steps(4); + settle(&mut h); + h +} + +fn open_tab_names(h: &Harness<'static, PdfCraftApp>) -> Vec { + let s = h.state(); + s.views.iter().filter_map(|v| s.session.get(v.id)).map(|d| d.name.clone()).collect() +} + +#[test] +fn an_attachment_comment_opens_its_file_from_the_page_menu() { + let mut h = attachment_harness(|app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "select").unwrap(); + }); + assert_eq!(comments(&h)[0].subtype, "FileAttachment"); + click_pt(&mut h, (110.0, 112.0)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the attachment comment is selected"); + let p = at(&h, 110.0, 112.0); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + h.get_by_label("Copy text"); + // The Attachments panel is closed, so these items can only come from the comment's menu. + h.get_by_label("Save attachment…"); + h.get_by_label("Open in a new tab").click(); + h.run_steps(4); + assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]); +} + +#[test] +fn an_attachment_comment_opens_its_file_from_the_card_menu() { + let mut h = attachment_harness(|app| app.set_option("panel", "comments").unwrap()); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.run_steps(2); + h.get_by_label("More").click(); + h.run_steps(2); + h.get_by_label("Copy text"); + h.get_by_label("Save attachment…"); + h.get_by_label("Open in a new tab").click(); + h.run_steps(4); + assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]); +} + /// Drag with the middle button (the mouse wheel) in small steps. fn middle_drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: egui::Modifiers::NONE }; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e54e5fc14a..938e87a95d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3812,8 +3812,8 @@ milestone = "M5" status = "shipped" commands = ["comment.attach"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] -notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel" +evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/ui-egui/tests/comments.rs::an_attachment_comment_opens_its_file_from_the_page_menu", "crates/ui-egui/tests/comments.rs::an_attachment_comment_opens_its_file_from_the_card_menu", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] +notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel. The comment's menu, on the page and in the Comments panel, saves the file or opens a PDF in a new tab (#2565)" [[feature]] id = "comment.record-audio"