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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 04:09:44 -04:00
parent bf58d1f6bf
commit 968d26a41e
14 changed files with 229 additions and 14 deletions

View File

@ -206,6 +206,12 @@ pub fn build(d: &Dict) -> Option<Stream> {
c.push_str(&note_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" {

View File

@ -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<u8>,
},
}
/// 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<AttachIcon> {
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);

View File

@ -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()
);
}

View File

@ -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]) }

View File

@ -458,13 +458,14 @@ pub fn tools() -> Vec<ToolDef> {
"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<ToolDef> {
"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 },

View File

@ -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 { .. }));
}

View File

@ -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"),

View File

@ -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",
}
}

View File

@ -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);
}

View File

@ -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) {

View File

@ -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<CommentTool> {
("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<Vec<[f64; 8]>>,
/// 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;

View File

@ -264,6 +264,8 @@ pub struct PrintCraftApp {
/// system clipboard (tests turn that off).
pub last_snapshot: Option<(u32, u32, Vec<u8>)>,
pub system_clipboard: bool,
/// Attach file: the file to attach instead of asking (tests, automation).
pub attach_override: Option<(String, Vec<u8>)>,
/// Where autosaves go (`None`: autosave off, e.g. on the web and in tests).
pub recovery: Option<RecoveryStore>,
/// 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(),

View File

@ -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);
}

View File

@ -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"