From 968d26a41ea80234ab3bc0b78d12f512cd0fe80f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 04:09:44 -0400 Subject: [PATCH] M5: Attach a file as a comment (push pin, paperclip, graph or tag icon in PrintCraft's own drawings; the file is embedded and listed under Attachments); comment_add type attachment Co-Authored-By: Claude Opus 5.5 --- crates/annot/src/appearance.rs | 21 ++++++++ crates/annot/src/lib.rs | 69 ++++++++++++++++++++++++++- crates/annot/src/tests.rs | 33 +++++++++++++ crates/automation/src/comments.rs | 12 +++++ crates/automation/src/tools.rs | 7 +-- crates/automation/tests/automation.rs | 5 ++ crates/engine/src/commands.rs | 1 + crates/engine/src/lib.rs | 7 ++- crates/ui-egui/src/canvas.rs | 3 ++ crates/ui-egui/src/comment_props.rs | 29 +++++++++++ crates/ui-egui/src/comments.rs | 29 ++++++++++- crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/tests/comments.rs | 14 ++++++ parity/acrobat-features.toml | 10 ++-- 14 files changed, 229 insertions(+), 14 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index e79351c5..eff0c771 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -206,6 +206,12 @@ pub fn build(d: &Dict) -> Option { c.push_str(¬e_icon(&icon, col)); return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); } + b"FileAttachment" => { + let col = stroke.unwrap_or([0.0, 0.47, 0.84]); + let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "PushPin".into()); + c.push_str(&attach_icon(&icon, col)); + return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); + } _ if markup => { let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; let col = stroke?; @@ -689,6 +695,21 @@ fn ellipse(x0: f64, y0: f64, x1: f64, y1: f64) -> String { /// PrintCraft's note icons, drawn in a 20 × 20 box: a speech bubble for `/Comment`, a page /// with a folded corner for everything else, both filled with the note colour. +/// File attachment icons in a 20 × 20 box: PrintCraft's own drawings. +fn attach_icon(name: &str, col: Rgb) -> String { + let mut s = format!("{}{}1.2 w 1 j 1 J\n", rg(col), rg_stroke(col)); + match name { + "Paperclip" => s.push_str("8 4 m 8 15 l 8 18 13 18 13 15 c 13 6 l 13 3 10 3 10 6 c 10 14 l S\n"), + "Graph" => s.push_str("3 3 m 3 17 l 3 3 m 17 3 l S\n5 3 3 6 re 9 3 3 10 re 13 3 3 13 re f\n"), + "Tag" => s.push_str("3 10 m 8 15 l 17 15 l 17 5 l 8 5 l h S\n7.3 10 m 7.3 10.7 6.7 11.3 6 11.3 c 5.3 11.3 4.7 10.7 4.7 10 c 4.7 9.3 5.3 8.7 6 8.7 c 6.7 8.7 7.3 9.3 7.3 10 c f\n"), + _ => { + // Push pin: a round head on a short needle. + s.push_str("10 2 m 10 9 l S\n6 9 m 14 9 l 14 11 l 12 12 l 12 16 l 13.5 17 l 13.5 18.5 l 6.5 18.5 l 6.5 17 l 8 16 l 8 12 l 6 11 l h f\n"); + } + } + s +} + fn note_icon(name: &str, col: Rgb) -> String { let mut s = format!("{}0.25 0.25 0.25 RG 0.8 w 1 j 1 J\n", rg(col)); if name == "Comment" { diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 93e5d22a..f29c2061 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -356,6 +356,39 @@ pub enum Shape { Caret { rect: [f64; 4], }, + /// Attach a file as a comment: an icon whose top-left corner is at `at`, holding `data` + /// (embedded) under the name `file`. + Attachment { + at: [f64; 2], + icon: AttachIcon, + file: String, + data: Vec, + }, +} + +/// The standard file attachment icons (§12.5.6.15). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AttachIcon { + #[default] + PushPin, + Paperclip, + Graph, + Tag, +} + +impl AttachIcon { + pub const ALL: [AttachIcon; 4] = [AttachIcon::PushPin, AttachIcon::Paperclip, AttachIcon::Graph, AttachIcon::Tag]; + pub fn name(self) -> &'static str { + match self { + AttachIcon::PushPin => "PushPin", + AttachIcon::Paperclip => "Paperclip", + AttachIcon::Graph => "Graph", + AttachIcon::Tag => "Tag", + } + } + pub fn from_name(n: &str) -> Option { + Self::ALL.into_iter().find(|i| i.name().eq_ignore_ascii_case(n)) + } } /// A rectangle `[x0 y0 x1 y1]` as a quad in Acrobat's order (top-left, top-right, bottom-left, @@ -380,6 +413,7 @@ impl Shape { Shape::Polygon { .. } => "Polygon", Shape::PolyLine { .. } => "PolyLine", Shape::Caret { .. } => "Caret", + Shape::Attachment { .. } => "FileAttachment", } } } @@ -473,7 +507,7 @@ impl Style { ([0.89, 0.13, 0.13], 2.0) } Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0), - Shape::Caret { .. } => ([0.0, 0.47, 0.84], 1.0), + Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0), Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0), Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0), Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5), @@ -653,7 +687,7 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> { let bad = |what: &str| AnnotError::Invalid(format!("invalid {what}")); let half = style.width.max(0.0) / 2.0; let r = match shape { - Shape::Note { at, .. } => { + Shape::Note { at, .. } | Shape::Attachment { at, .. } => { if !finite(at) { return Err(bad("position")); } @@ -770,6 +804,7 @@ fn subject(shape: &Shape) -> &'static str { Shape::PolyLine { .. } => "Polygonal Line", Shape::Callout { .. } => "Callout", Shape::Caret { .. } => "Inserted Text", + Shape::Attachment { .. } => "File Attachment", } } @@ -899,6 +934,36 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"C".to_vec(), rgb(style.color)); d.set(b"Sy".to_vec(), Object::name("None")); } + Shape::Attachment { icon, file, data, .. } => { + if file.trim().is_empty() { + return Err(AnnotError::Invalid("the attached file needs a name".into())); + } + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name(icon.name())); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + // The file: an embedded file stream inside a file specification (§7.11.4). + let mut params = Dict::new(); + params.set(b"Size".to_vec(), Object::Int(data.len() as i64)); + if let Some(date) = &meta.date { + params.set(b"ModDate".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + } + let mut ef = Dict::new(); + ef.set(b"Type".to_vec(), Object::name("EmbeddedFile")); + ef.set(b"Params".to_vec(), Object::Dict(params)); + let ef = doc.add(Object::Stream(printcraft_cos::Stream::flate(ef, data))); + let mut efd = Dict::new(); + efd.set(b"F".to_vec(), Object::Ref(ef)); + efd.set(b"UF".to_vec(), Object::Ref(ef)); + let mut fs = Dict::new(); + fs.set(b"Type".to_vec(), Object::name("Filespec")); + fs.set(b"F".to_vec(), PdfString::text(file.trim())); + fs.set(b"UF".to_vec(), PdfString::text(file.trim())); + fs.set(b"EF".to_vec(), Object::Dict(efd)); + d.set(b"FS".to_vec(), Object::Ref(doc.add(Object::Dict(fs)))); + if new.contents.is_empty() { + d.set(b"Contents".to_vec(), PdfString::text(file.trim())); + } + } Shape::Callout { rect: tb, knee, point, .. } => { let tb = normalize(*tb); let attach = callout_attach(tb, *knee); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 38dddb69..c495cfb1 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -527,3 +527,36 @@ fn replace_text_groups_a_strikeout_and_a_caret() { assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret"))); assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r")).is_err()); } + +#[test] +fn files_attach_as_comments() { + let mut doc = fixture(); + let data = b"col1,col2\n1,2\n".to_vec(); + let shape = Shape::Attachment { at: [300.0, 500.0], icon: AttachIcon::Paperclip, file: "data.csv".into(), data: data.clone() }; + let i = add_annotation( + &mut doc, + &NewAnnotation { page: 0, shape, style: Style::default(), contents: String::new(), author: "Ada".into() }, + &meta("f"), + ) + .unwrap(); + let doc = reopen(&doc); + let a = &list(&doc, 0)[i]; + assert_eq!((a.name(b"Subtype"), a.name(b"Name")), (Some(&b"FileAttachment"[..]), Some(&b"Paperclip"[..]))); + assert_eq!(text(a, b"Contents"), "data.csv", "the file name describes it"); + let fs = doc.resolve(a.get(b"FS").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(text(&fs, b"UF"), "data.csv"); + let ef = fs.get(b"EF").and_then(|e| e.as_dict()).and_then(|e| e.reference(b"F")).unwrap(); + let Object::Stream(s) = &*doc.get(ef) else { panic!() }; + assert_eq!(s.decoded().unwrap(), data); + assert!(ap_content(&doc, a).contains(" c\n") || ap_content(&doc, a).contains(" c "), "a drawn paperclip"); + let mut doc = doc; + let bad = Shape::Attachment { at: [0.0, 0.0], icon: AttachIcon::Tag, file: " ".into(), data: Vec::new() }; + assert!( + add_annotation( + &mut doc, + &NewAnnotation { page: 0, shape: bad, style: Style::default(), contents: String::new(), author: String::new() }, + &meta("x") + ) + .is_err() + ); +} diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 136b3f4c..cb3b7703 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -271,6 +271,18 @@ impl Automation { }; Shape::Callout { rect, knee, point, font_size: a.opt_num("font_size")?.unwrap_or(10.0) } } + "attachment" => { + let [x, y] = a.need::<2>("at", "an attachment (its icon's top-left)")?; + let path = self.resolve(a.str("path")?, false)?; + let data = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let icon = match a.opt_str("icon")? { + Some(n) => printcraft_engine::AttachIcon::from_name(n) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown icon {n:?} (PushPin, Paperclip, Graph, Tag)")))?, + None => printcraft_engine::AttachIcon::PushPin, + }; + Shape::Attachment { at: to_user(&info, x, y), icon, file, data } + } "caret" => { let [x, y] = a.need::<2>("at", "a caret (the insertion point on the baseline)")?; Shape::Caret { rect: rect_to_user(&info, [x - 4.0, y, x + 4.0, y + 8.0]) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1930fec7..18ada45f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -458,13 +458,14 @@ pub fn tools() -> Vec { "Add a comment as Acrobat's commenting tools do. Geometry is in points with the origin at the top-left of the displayed page, y down (as in page_render images at 72 dpi and text_find rects). \ note: `at` [x, y] (icon top-left). stamp: `at` [x, y] (its centre) and `stamp`: approved, completed, confidential, draft, final, for comment, for public release, information only, not approved, not for public release, preliminary results, void, accepted, initial here, rejected, sign here, witness; dynamic: true for the dynamic approved/confidential/received/reviewed/revised stamps with a By … at … line. highlight/underline/strikeout/squiggly: `find` (text on the page to mark; every match with all: true) or `quads`. \ rectangle/oval/textbox: `rect` [x0, y0, x1, y1]. line/arrow: `from`, `to`. ink: `strokes` [[[x, y], …], …]. \ - polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. Undoable.", + polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable.", ) .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, - "type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret"] }, + "type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] }, + "path": { "type": "string", "description": "attachment: the file to attach." }, "points": { "type": "array", "items": point(), "minItems": 2 }, "knee": point(), "contents": { "type": "string", "description": "The comment text (what a text box shows)." }, @@ -479,7 +480,7 @@ pub fn tools() -> Vec { "stamp": { "type": "string" }, "dynamic": { "type": "boolean" }, "strokes": { "type": "array", "items": { "type": "array", "items": point() } }, - "icon": { "type": "string", "enum": ["Comment", "Note", "Help", "Insert", "Key", "NewParagraph", "Paragraph"] }, + "icon": { "type": "string", "description": "note: Comment, Note, Help, Insert, Key, NewParagraph, Paragraph; attachment: PushPin, Paperclip, Graph, Tag." }, "color": color(), "fill": color(), "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 2709603e..fb3bcdc6 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -957,6 +957,11 @@ fn drawing_comments_through_tools() { let strike = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "StrikeOut").unwrap().clone(); assert_eq!(strike["replies"][0]["contents"], "second page", "the caret threads under the strikeout"); assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2" })), Err(ToolError::InvalidArgs(_)))); + // A file attached as a comment. + std::fs::write(dir.join("notes.txt"), b"remember").unwrap(); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "attachment", "path": "notes.txt", "at": [250, 20], "icon": "Paperclip" })); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert!(info.to_string().contains("notes.txt"), "{info}"); let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); assert!(matches!(png[0], Content::Png { .. })); } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index ece38ab1..9e9b7eb5 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -187,6 +187,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.callout", "Add a callout", None, None, Annotate, "message-square-quote"), c("comment.caret", "Insert text", None, None, Annotate, "text-cursor-input"), c("comment.replace", "Replace text", None, None, Annotate, "replace"), + c("comment.attach", "Attach a file", None, None, Annotate, "paperclip"), c("form.fields", "Form fields panel", VIEW, None, Document, "list"), c("form.clear", "Clear form", EDIT, None, FillForms, "eraser"), c("comment.flatten", "Flatten comments", None, None, HasComments, "layers"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b41418c3..0cfd50d9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -34,8 +34,8 @@ pub use printcraft_forms::{ pub use printcraft_annot::appearance as annot_text; pub use printcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; pub use printcraft_annot::{ - FillMark, Markup, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, - Style, rect_quad, + AttachIcon, FillMark, Markup, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, + StampKind, Style, rect_quad, }; pub use printcraft_optimize as optimize; pub use printcraft_print as print; @@ -81,6 +81,8 @@ enum Scope { fn scope_of(edit: &Edit) -> Scope { match edit { + // A file attachment also changes the Attachments list. + Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, Edit::AddAnnotation(_) | Edit::DeleteAnnotation { .. } | Edit::SetAnnotationContents { .. } @@ -830,6 +832,7 @@ fn annotation_noun(s: &Shape) -> &'static str { Shape::PolyLine { .. } => "connected lines", Shape::Callout { .. } => "callout", Shape::Caret { .. } => "inserted text", + Shape::Attachment { .. } => "file attachment", } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 96532772..d5a82cea 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1450,6 +1450,9 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { } } app.quick_tool = tool; + if let Some((page, at)) = app.views[index].comments.attach_at.take() { + app.attach_file_comment(page, at); + } if let Some((p, i)) = open_props { app.open_comment_props(p, i); } diff --git a/crates/ui-egui/src/comment_props.rs b/crates/ui-egui/src/comment_props.rs index 57cafa0d..2bffc6cc 100644 --- a/crates/ui-egui/src/comment_props.rs +++ b/crates/ui-egui/src/comment_props.rs @@ -29,6 +29,35 @@ const ICONS: [NoteIcon; 7] = [NoteIcon::Comment, NoteIcon::Note, NoteIcon::Help, NoteIcon::Insert, NoteIcon::Key, NoteIcon::NewParagraph, NoteIcon::Paragraph]; impl PrintCraftApp { + /// Attach file: ask for a file (or take `attach_override`) and attach it at `at`. + pub fn attach_file_comment(&mut self, page: usize, at: [f64; 2]) { + let picked = match self.attach_override.take() { + Some(f) => Some(f), + #[cfg(not(target_arch = "wasm32"))] + None => rfd::FileDialog::new().set_title("Attach a file").pick_file().and_then(|p| { + let data = std::fs::read(&p).ok()?; + Some((p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), data)) + }), + #[cfg(target_arch = "wasm32")] + None => None, + }; + let Some((file, data)) = picked else { return }; + let tool = crate::comments::CommentTool::Attach; + let shape = printcraft_engine::Shape::Attachment { at, icon: printcraft_engine::AttachIcon::PushPin, file, data }; + let edit = Edit::AddAnnotation(printcraft_engine::NewAnnotation { + page, + shape, + style: self.comment_prefs.style(tool), + contents: String::new(), + author: self.comment_prefs.author.clone(), + }); + // A one-shot tool: back to Select unless pinned (apply_edit acts on this). + if let Some((i, _)) = self.active_ids() { + self.views[i].comments.tool_done = true; + } + self.apply_edit(edit); + } + /// Make Current Properties Default: new comments of this kind take this one's colour, /// opacity and line width. pub fn make_comment_default(&mut self, page: usize, index: usize) { diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 20ed1d9b..02401d4e 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -41,11 +41,12 @@ pub enum CommentTool { Callout, Caret, ReplaceText, + Attach, } /// The quick-bar flyout groups, in Acrobat's order: Comment ▸, Highlight ▸, Draw ▸. pub const GROUPS: [&[CommentTool]; 3] = [ - &[CommentTool::Note, CommentTool::TextBox, CommentTool::Callout], + &[CommentTool::Note, CommentTool::TextBox, CommentTool::Callout, CommentTool::Attach], &[CommentTool::Highlight, CommentTool::Underline, CommentTool::StrikeOut, CommentTool::Caret, CommentTool::ReplaceText], &[ CommentTool::Ink, @@ -59,8 +60,9 @@ pub const GROUPS: [&[CommentTool]; 3] = [ ], ]; -pub const ALL: [CommentTool; 16] = [ +pub const ALL: [CommentTool; 17] = [ CommentTool::ReplaceText, + CommentTool::Attach, CommentTool::Polygon, CommentTool::PolyLine, CommentTool::Cloud, @@ -97,6 +99,7 @@ impl CommentTool { Self::Callout => "comment.callout", Self::Caret => "comment.caret", Self::ReplaceText => "comment.replace", + Self::Attach => "comment.attach", } } @@ -122,6 +125,7 @@ impl CommentTool { Self::Callout => "Add a callout", Self::Caret => "Insert text", Self::ReplaceText => "Replace text", + Self::Attach => "Attach file", } } @@ -143,6 +147,7 @@ impl CommentTool { Self::Callout => "message-square-quote", Self::Caret => "text-cursor-input", Self::ReplaceText => "replace", + Self::Attach => "paperclip", } } @@ -193,6 +198,7 @@ impl CommentTool { Self::Callout => Shape::Callout { rect: [0.0; 4], knee: [0.0; 2], point: [0.0; 2], font_size: 10.0 }, Self::Caret => Shape::Caret { rect: [0.0; 4] }, Self::ReplaceText => Shape::TextMarkup { kind: Markup::StrikeOut, quads: Vec::new() }, + Self::Attach => Shape::Attachment { at: [0.0; 2], icon: printcraft_engine::AttachIcon::PushPin, file: String::new(), data: Vec::new() }, } } } @@ -281,6 +287,7 @@ pub fn tool_for(a: &Annotation) -> Option { ("Polygon", _) => CommentTool::Polygon, ("PolyLine", _) => CommentTool::PolyLine, ("Caret", _) => CommentTool::Caret, + ("FileAttachment", _) => CommentTool::Attach, _ => return None, }) } @@ -369,6 +376,8 @@ pub struct CommentView { pub default_request: Option<(usize, usize)>, /// 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. + pub attach_at: Option<(usize, [f64; 2])>, pub search_focus: bool, /// Where the canvas context menu was opened: (page, user-space point). pub context_at: Option<(usize, [f64; 2])>, @@ -630,6 +639,22 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, } true } + QuickTool::Comment(CommentTool::Attach) => { + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + cv.attach_at = Some((cx.page, cx.to_user(p))); + cv.selected = None; + } + true + } QuickTool::Comment(CommentTool::Caret) => { if !cx.allowed { return false; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 71ba44a1..450929cd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -264,6 +264,8 @@ pub struct PrintCraftApp { /// system clipboard (tests turn that off). pub last_snapshot: Option<(u32, u32, Vec)>, pub system_clipboard: bool, + /// Attach file: the file to attach instead of asking (tests, automation). + pub attach_override: Option<(String, Vec)>, /// 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. @@ -371,6 +373,7 @@ impl PrintCraftApp { page_clipboard: None, last_snapshot: None, system_clipboard: true, + attach_override: None, recovery: None, recoverable: Vec::new(), recovery_keys: Default::default(), diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index f7422b4c..977b9019 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -487,3 +487,17 @@ fn replacing_text_strikes_it_and_adds_a_caret() { assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Replace text")); assert_eq!(h.state().quick_tool, QuickTool::Select); } + +#[test] +fn attaching_a_file_as_a_comment() { + let mut h = harness(|app| app.set_option("quick", "attach").unwrap()); + h.state_mut().attach_override = Some(("notes.txt".into(), b"remember the milk".to_vec())); + click_pt(&mut h, (250.0, 180.0)); + h.run_steps(3); + let c = comments(&h); + assert_eq!((c.len(), c[0].subtype.as_str(), c[0].contents.as_deref()), (1, "FileAttachment", Some("notes.txt"))); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert!(doc.info.attachments.iter().any(|a| a.name == "notes.txt"), "listed in the Attachments panel"); + assert_eq!(s.quick_tool, QuickTool::Select); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 177db65b..dbcc9e3c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3695,11 +3695,11 @@ title = "Attach a file as a comment" area = "E" tier = "P1" milestone = "M5" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +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" [[feature]] id = "comment.record-audio"