Comments: an attachment comment's menu saves or opens its file (fixes #2565) (#2585)

This commit is contained in:
Marco Arnulfo
2026-10-18 03:46:10 -04:00
committed by GitHub
parent cef2255da5
commit 946c6fdfc4
7 changed files with 127 additions and 3 deletions
+4
View File
@@ -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);
}
+21
View File
@@ -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<Vec<[f64; 8]>>,
/// 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<CanvasAction> {
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,
+3 -1
View File
@@ -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();
+9
View File
@@ -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 {
+3
View File
@@ -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);
}
+85
View File
@@ -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<u8> {
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<String> {
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 };
+2 -2
View File
@@ -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"