diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index d7eb8a2baa..aff732462f 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -703,6 +703,24 @@ impl Automation { "content_update" => self.content_update(&a)?, "content_delete" => self.content_delete(&a)?, "doc_print" => self.doc_print(&a)?, + "attachment_add" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let name = match a.opt_str("name")? { + Some(n) => n.to_string(), + None => path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + }; + let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string); + let description = a.opt_str("description")?.map(str::to_string); + let before: Vec = self.doc(&a)?.info.attachments.iter().map(|x| x.name.clone()).collect(); + let out = self.apply(&a, Edit::AddAttachment { name, bytes, description, mime })?; + let key = self.doc(&a)?.info.attachments.iter().map(|x| x.name.clone()).find(|n| !before.contains(n)).unwrap_or_default(); + json!({ "name": key, "document": out }) + } + "attachment_delete" => { + let edit = Edit::DeleteAttachment { key: a.str("name")?.into() }; + self.apply(&a, edit)? + } "doc_remove_hidden" => self.doc_remove_hidden(&a)?, "doc_audit_redactions" => self.doc_audit_redactions(&a)?, "fill_sign_add" => self.fill_sign_add(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index cfdeb1c16f..558b2968ca 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -129,6 +129,12 @@ pub fn tools() -> Vec { "dpi": {"type":"number","minimum":72,"maximum":600}, "language": {"type":"string"}, "skip_text_pages": {"type":"boolean"} }), &["paths","out_dir","steps"])), + t("attachment_add", "Attach a file", "Attach the file at path to the document (document level, the Attachments panel; not a paper-clip comment on a page). name defaults to the file's name; the attachment's key, unique in the document, is returned. Undoable.") + .cmd("edit.attach") + .with(schema(json!({"doc": doc(), "path": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "path"])), + t("attachment_delete", "Delete an attachment", "Remove the document-level attachment with this name (as doc_info lists it). Undoable.") + .destructive() + .with(schema(json!({"doc": doc(), "name": {"type": "string"}}), &["doc", "name"])), t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.") .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 0ff3930b4a..3249cff552 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5672,3 +5672,43 @@ fn absolute_gotor_consent_does_not_bypass_validation_or_passwords() { assert_eq!(opened["document"]["encrypted"], true); assert_eq!(opened["destination_applied"], true); } + +#[test] +fn attaching_and_deleting_files_through_the_tools() { + let dir = workdir("attach"); + std::fs::write(dir.join("notes.txt"), b"keep this with the PDF").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"].as_array().unwrap().is_empty()); + // A text file and a PDF, the PDF under a name of its own; the second notes.txt is kept apart. + let r = ok(&mut a, "attachment_add", json!({ "doc": doc, "path": "notes.txt", "description": "Meeting notes" })); + assert_eq!(r["name"], "notes.txt"); + let r = ok(&mut a, "attachment_add", json!({ "doc": doc, "path": "b.pdf", "name": "annex.pdf" })); + assert_eq!(r["name"], "annex.pdf"); + let r = ok(&mut a, "attachment_add", json!({ "doc": doc, "path": "notes.txt" })); + assert_eq!(r["name"], "notes.txt (2)"); + let listed = ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"].clone(); + let names: Vec<&str> = listed.as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["annex.pdf", "notes.txt", "notes.txt (2)"]); + assert_eq!(listed[1]["description"], "Meeting notes"); + assert_eq!(listed[1]["size"], 22); + // Saved and reopened, the attachment is there with its bytes. + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "with-notes.pdf" })); + let saved = std::fs::read(dir.join("with-notes.pdf")).unwrap(); + let info = pdfcraft_render::inspect(std::sync::Arc::new(saved.clone()), None).unwrap(); + let att = info.attachments.iter().find(|x| x.name == "notes.txt").expect("notes.txt is in the file"); + assert_eq!(pdfcraft_render::attachment_data(&saved, None, att).unwrap(), b"keep this with the PDF"); + // Deleting one leaves the others; a name that is not there is refused; undo brings it back. + ok(&mut a, "attachment_delete", json!({ "doc": doc, "name": "notes.txt" })); + let names: Vec = ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"] + .as_array() + .unwrap() + .iter() + .map(|x| x["name"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(names, ["annex.pdf", "notes.txt (2)"]); + assert!(a.call("attachment_delete", &json!({ "doc": doc, "name": "notes.txt" })).is_err()); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"].as_array().unwrap().len(), 3); + assert!(a.call("attachment_add", &json!({ "doc": doc, "path": "missing.txt" })).is_err(), "a file that is not there is refused"); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 991c86a9ca..6f7e25f851 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -133,7 +133,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Link", "link-2", "edit.link", Ready), item("Bates numbering", "hash", "edit.bates", Ready), item("Background", "palette", "edit.background", Ready), - item("Attach file", "paperclip", "edit.attach", Planned("M12")), + item("Attach file", "paperclip", "edit.attach", Ready), ], }, ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index c3294dc1e2..5fd78441de 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -267,6 +267,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("edit.text", "Add text", None, None, Modification, "type"), c("edit.edit_text", "Edit text & images", None, None, Modification, "text-cursor-input"), c("edit.link", "Add or edit links", None, None, Modification, "link-2"), + c("edit.attach", "Attach a file…", EDIT, None, Modification, "paperclip"), c("edit.links_from_urls", "Create links from URLs", None, None, Modification, "link-2"), c("edit.remove_links", "Remove all links", None, None, Modification, "trash-2"), c("edit.bates", "Add Bates numbering…", None, None, Modification, "hash"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 15c0551b14..97a46bcc06 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -41,7 +41,7 @@ pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use text_preview::TextEditPreview; -pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, split_ranges}; +pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, mime_for_name, split_ranges}; /// One file produced by a split: (1-based first page, last page, PDF bytes). pub use pdfcraft_organize::LabelStyle; @@ -197,6 +197,8 @@ fn uses_scripts(edit: &Edit) -> bool { fn scope_of(edit: &Edit) -> Scope { match edit { Edit::SetInfo { .. } => Scope::Metadata, + // The Attachments list is read from the whole document. + Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, // A file attachment also changes the Attachments list. Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, Edit::AddMeasurement(_) @@ -1330,6 +1332,18 @@ pub enum Edit { }, /// Document Properties ▸ Initial View (and the language and binding). SetInitialView(Box), + /// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under + /// `name`, made unique; `mime` is the embedded file's `/Subtype` when known. + AddAttachment { + name: String, + bytes: Vec, + description: Option, + mime: Option, + }, + /// Remove the document-level attachment whose name-tree key is `key`. + DeleteAttachment { + key: String, + }, /// Build a tagged structure tree for an untagged, text-only document. AutoTagText, /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is @@ -1813,6 +1827,8 @@ impl Edit { Edit::DuplicateField { .. } => Label::of("Duplicate field"), Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), Edit::SetInitialView(_) => Label::of("Change initial view"), + Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), + Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), Edit::AutoTagText => Label::of("Automatically tag text"), Edit::SetAltText { .. } => Label::of("Set alternate text"), Edit::SetTagProperty { .. } => Label::of("Change tag properties"), @@ -2002,6 +2018,8 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } | Edit::SetInitialView(_) + | Edit::AddAttachment { .. } + | Edit::DeleteAttachment { .. } | Edit::AutoTagText | Edit::SetAltText { .. } | Edit::SetTagProperty { .. } @@ -2192,6 +2210,10 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::EraseInk { page, index, path, radius } => { pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; } + Edit::AddAttachment { name, bytes, description, mime } => { + pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; + } + Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, Edit::ReplaceText { page, quads, text, author, strike, caret } => { pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &cx.meta())?; } diff --git a/crates/organize/src/attachments.rs b/crates/organize/src/attachments.rs new file mode 100644 index 0000000000..d785f1a4db --- /dev/null +++ b/crates/organize/src/attachments.rs @@ -0,0 +1,159 @@ +//! Document-level file attachments (Acrobat: Edit ▸ Attach file; the Attachments panel): the +//! `/Names /EmbeddedFiles` name tree of file specifications, each with its embedded stream. +//! +//! A FileAttachment annotation (a paper clip on a page) is a comment and lives in the annot +//! crate; this is the document level only. + +use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; + +use crate::OrganizeError; +use crate::import::{name_tree_entries, set_attachments}; + +/// The keys of the document-level attachments, in name-tree order. +pub fn attachment_keys(doc: &Document) -> Vec { + entries(doc).into_iter().map(|(k, _)| PdfString::literal(k).to_text()).collect() +} + +/// Attach `bytes` to the document under `name` (a file name; any folder part is dropped). The +/// name tree's key is the name, made unique with " (2)", " (3)"… when it is already taken, and +/// is returned so the caller can find the attachment again. `date` is a PDF date string for the +/// embedded file's `/ModDate`; `mime` its `/Subtype` when known. +pub fn add_attachment( + doc: &mut Document, + name: &str, + bytes: &[u8], + description: Option<&str>, + mime: Option<&str>, + date: Option<&str>, +) -> Result { + let name = file_name(name); + if name.is_empty() { + return Err(OrganizeError::Invalid("the attachment needs a file name".into())); + } + let mut all = entries(doc); + let taken: Vec = all.iter().map(|(k, _)| PdfString::literal(k.clone()).to_text()).collect(); + let mut key = name.clone(); + let mut n = 2; + while taken.contains(&key) { + key = format!("{name} ({n})"); + n += 1; + } + + let mut params = Dict::new(); + params.set(b"Size".to_vec(), Object::Int(bytes.len() as i64)); + if let Some(d) = date { + params.set(b"ModDate".to_vec(), Object::String(PdfString::literal(d.as_bytes().to_vec()))); + } + let mut ef = Dict::new(); + ef.set(b"Type".to_vec(), Object::name("EmbeddedFile")); + if let Some(m) = mime.map(str::trim).filter(|m| !m.is_empty()) { + ef.set(b"Subtype".to_vec(), Object::Name(m.as_bytes().to_vec())); + } + ef.set(b"Params".to_vec(), Object::Dict(params)); + let stream = doc.add(Object::Stream(Stream::flate(ef, bytes))); + + let mut spec = Dict::new(); + spec.set(b"Type".to_vec(), Object::name("Filespec")); + spec.set(b"F".to_vec(), Object::String(PdfString::text(&key))); + spec.set(b"UF".to_vec(), Object::String(PdfString::text(&key))); + if let Some(d) = description.map(str::trim).filter(|d| !d.is_empty()) { + spec.set(b"Desc".to_vec(), Object::String(PdfString::text(d))); + } + let mut files = Dict::new(); + files.set(b"F".to_vec(), Object::Ref(stream)); + files.set(b"UF".to_vec(), Object::Ref(stream)); + spec.set(b"EF".to_vec(), Object::Dict(files)); + let spec = doc.add(spec); + + all.push((PdfString::text(&key).bytes, Object::Ref(spec))); + set_attachments(doc, all)?; + Ok(key) +} + +/// Remove the document-level attachment whose name-tree key is `key`. The embedded stream is +/// left for the writer's garbage collection, as with every other removal. +pub fn delete_attachment(doc: &mut Document, key: &str) -> Result<(), OrganizeError> { + let mut all = entries(doc); + let before = all.len(); + all.retain(|(k, _)| PdfString::literal(k.clone()).to_text() != key); + if all.len() == before { + return Err(OrganizeError::Invalid(format!("there is no attachment named {key}"))); + } + if all.is_empty() { clear_embedded_files(doc) } else { set_attachments(doc, all) } +} + +/// The `/EmbeddedFiles` entries (key, file specification), in tree order. +fn entries(doc: &Document) -> Vec<(Vec, Object)> { + let Some(tree) = doc + .root() + .and_then(|r| doc.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"Names").map(|n| doc.resolve(n))) + .and_then(|n| n.as_dict().and_then(|d| d.get(b"EmbeddedFiles").cloned())) + else { + return Vec::new(); + }; + let mut out = Vec::new(); + name_tree_entries(doc, &tree, 0, &mut out); + out +} + +/// Drop `/EmbeddedFiles` from the catalog's `/Names` (and `/Names` itself when nothing is left). +fn clear_embedded_files(doc: &mut Document) -> Result<(), OrganizeError> { + let root = doc.root().ok_or(OrganizeError::NoPageTree)?; + let names = doc.get(root).as_dict().and_then(|c| c.get(b"Names").cloned()); + match names { + Some(Object::Ref(r)) => { + doc.update_dict(r, |n| { + n.remove(b"EmbeddedFiles"); + })?; + } + Some(Object::Dict(mut n)) => { + n.remove(b"EmbeddedFiles"); + doc.update_dict(root, |c| { + if n.is_empty() { + c.remove(b"Names"); + } else { + c.set(b"Names".to_vec(), Object::Dict(n)); + } + })?; + } + _ => {} + } + Ok(()) +} + +/// The last path component of `name`, trimmed: a dragged-in path or a folder part never becomes +/// the attachment's name. +fn file_name(name: &str) -> String { + name.rsplit(['/', '\\']).next().unwrap_or(name).trim().to_string() +} + +/// A media type from the file's extension, for the embedded file's `/Subtype`; `None` when it +/// is not one of the common kinds (the attachment is still fine without it). +pub fn mime_for_name(name: &str) -> Option<&'static str> { + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?; + Some(match ext.as_str() { + "pdf" => "application/pdf", + "txt" | "log" | "md" => "text/plain", + "csv" => "text/csv", + "htm" | "html" => "text/html", + "xml" => "text/xml", + "json" => "application/json", + "zip" => "application/zip", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "tif" | "tiff" => "image/tiff", + "svg" => "image/svg+xml", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" => "application/vnd.ms-powerpoint", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "rtf" => "application/rtf", + "eml" => "message/rfc822", + "msg" => "application/vnd.ms-outlook", + _ => return None, + }) +} diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index c420d2d59c..37684882c6 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -486,7 +486,7 @@ fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8) -> Opt } /// All (key, value) pairs of a name tree, in order. -fn name_tree_entries(src: &Document, node: &Object, depth: u8, out: &mut Vec<(Vec, Object)>) { +pub(crate) fn name_tree_entries(src: &Document, node: &Object, depth: u8, out: &mut Vec<(Vec, Object)>) { if depth > 32 { return; } @@ -1075,18 +1075,24 @@ fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) -> Result<(), OrganizeError> { +pub(crate) fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> Result<(), OrganizeError> { if entries.is_empty() { return Ok(()); } + // Names are compared and numbered as text: a key written as UTF-16 (a name with an accent + // or Chinese letters) must not get ASCII bytes appended to it. let mut seen = std::collections::HashSet::new(); for (k, _) in entries.iter_mut() { - let base = k.clone(); + let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text(); + let mut text = base.clone(); let mut n = 2; - while !seen.insert(k.clone()) { - *k = [base.as_slice(), format!(" ({n})").as_bytes()].concat(); + while !seen.insert(text.clone()) { + text = format!("{base} ({n})"); n += 1; } + if text != base { + *k = pdfcraft_cos::PdfString::text(&text).bytes; + } } entries.sort_by(|a, b| a.0.cmp(&b.0)); let mut names = Vec::new(); @@ -1098,6 +1104,14 @@ fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> R tree.set(b"Names".to_vec(), Object::Array(names)); let tree = dst.add(tree); let root = dst.root().ok_or(OrganizeError::NoPageTree)?; + // `/Names` may be an indirect dictionary (Acrobat writes it so): update it where it is, so + // its other trees (/Dests, /JavaScript…) stay. + if let Some(Object::Ref(names)) = dst.get(root).as_dict().and_then(|c| c.get(b"Names").cloned()) + && dst.get(names).as_dict().is_some() + { + dst.update_dict(names, |n| n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree)))?; + return Ok(()); + } dst.update_dict(root, |c| { let mut n = match c.get(b"Names") { Some(Object::Dict(d)) => d.clone(), diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 7b366bad9d..f008bb11b6 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -13,6 +13,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; +mod attachments; mod boxes; mod dedupe; mod import; @@ -23,6 +24,7 @@ mod prune; pub mod view; mod xmp; +pub use attachments::{add_attachment, attachment_keys, delete_attachment, mime_for_name}; pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; pub use dedupe::dedupe_resources; pub use import::{ diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index e07af72a1c..4bb3b1e653 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2441,3 +2441,84 @@ fn combine_field_conflicts_previews_each_file_in_order() { assert_eq!(preview[1], [("Text1".to_string(), "Text1-1".to_string())]); assert_eq!(preview[2], [("Text1".to_string(), "Text1-2".to_string())], "the cascade continues past the earlier rename"); } + +/// What Edit ▸ Attach file added survives Combine files (upstream's writer), names +/// with accents included, and the combined document takes more attachments and deletions. +#[test] +fn attachments_added_in_the_app_survive_combine_and_more_can_be_attached() { + let embedded = |doc: &Document, key: &str| -> (Vec, Option) { + let names = doc.resolve(catalog(doc).get(b"Names").unwrap()).as_dict().cloned().unwrap(); + let tree = doc.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap(); + let pairs = tree.get(b"Names").and_then(|n| n.as_array()).cloned().unwrap(); + let at = pairs.chunks(2).position(|p| p[0].as_string().unwrap().to_text() == key).unwrap_or_else(|| panic!("no attachment {key}")); + let spec = doc.resolve(&pairs[at * 2 + 1]).as_dict().cloned().unwrap(); + let desc = spec.get(b"Desc").and_then(Object::as_string).map(|s| s.to_text()); + let ef = spec.get(b"EF").and_then(Object::as_dict).unwrap().get(b"F").cloned().unwrap(); + let Object::Stream(stream) = doc.resolve(&ef).as_ref().clone() else { panic!("the embedded file is a stream") }; + (stream.decoded().unwrap(), desc) + }; + let mut a = Document::open(Arc::new(fixture())).unwrap(); + add_attachment(&mut a, "invoice.csv", b"a,b", Some("March"), Some("text/csv"), None).unwrap(); + add_attachment(&mut a, "año.txt", b"uno", None, None, None).unwrap(); + let mut b = Document::open(Arc::new(fixture())).unwrap(); + add_attachment(&mut b, "invoice.csv", b"x,y", None, None, None).unwrap(); + add_attachment(&mut b, "año.txt", b"dos", None, None, None).unwrap(); + let a = save_and_reopen(&a); + let b = save_and_reopen(&b); + let mut out = full_roundtrip(&combine(&[("A", &a), ("B", &b)]).unwrap()); + // Both files' attachments, the second copies numbered as text (not bytes glued onto UTF-16). + assert_eq!(attachment_keys(&out), ["invoice.csv", "invoice.csv (2)", "año.txt", "año.txt (2)"]); + assert_eq!(embedded(&out, "invoice.csv"), (b"a,b".to_vec(), Some("March".into()))); + assert_eq!(embedded(&out, "invoice.csv (2)"), (b"x,y".to_vec(), None)); + assert_eq!(embedded(&out, "año.txt (2)").0, b"dos"); + // The combined document goes on taking attachments, and losing them, like any other. + assert_eq!(add_attachment(&mut out, "invoice.csv", b"z", None, None, None).unwrap(), "invoice.csv (3)"); + delete_attachment(&mut out, "invoice.csv (2)").unwrap(); + let out = full_roundtrip(&out); + assert_eq!(attachment_keys(&out), ["invoice.csv", "invoice.csv (3)", "año.txt", "año.txt (2)"]); + assert_eq!(embedded(&out, "invoice.csv (3)").0, b"z"); +} + +#[test] +fn attachments_are_added_listed_and_deleted() { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + assert!(attachment_keys(&doc).is_empty()); + // A path loses its folder; the key is the file name. + let key = add_attachment(&mut doc, "/Users/eva/Desktop/invoice.csv", b"a,b\n1,2\n", Some("March"), Some("text/csv"), Some("D:20261012120000Z")) + .unwrap(); + assert_eq!(key, "invoice.csv"); + // The same name again is kept apart. + assert_eq!(add_attachment(&mut doc, "invoice.csv", b"x", None, None, None).unwrap(), "invoice.csv (2)"); + assert_eq!(add_attachment(&mut doc, " ", b"x", None, None, None), Err(OrganizeError::Invalid("the attachment needs a file name".into()))); + let doc = save_and_reopen(&doc); + assert_eq!(attachment_keys(&doc), ["invoice.csv", "invoice.csv (2)"]); + // What was written: a Filespec with the embedded stream, its size and type, and the description. + let root = doc.root().unwrap(); + let names = doc.get(root).as_dict().unwrap().get(b"Names").cloned().unwrap(); + let tree = doc.resolve(&names).as_dict().unwrap().get(b"EmbeddedFiles").cloned().unwrap(); + let tree = doc.resolve(&tree); + let pairs = tree.as_dict().unwrap().get(b"Names").cloned().unwrap(); + let pairs = doc.resolve(&pairs).as_array().cloned().unwrap(); + assert_eq!(pairs.len(), 4); + let spec = doc.resolve(&pairs[1]); + let spec = spec.as_dict().unwrap(); + assert_eq!(spec.get(b"Type"), Some(&Object::name("Filespec"))); + assert_eq!(spec.get(b"UF").and_then(Object::as_string).map(PdfString::to_text).as_deref(), Some("invoice.csv")); + assert_eq!(spec.get(b"Desc").and_then(Object::as_string).map(PdfString::to_text).as_deref(), Some("March")); + let ef = spec.get(b"EF").and_then(Object::as_dict).unwrap().get(b"F").cloned().unwrap(); + let ef = doc.resolve(&ef); + let Object::Stream(stream) = ef.as_ref() else { panic!("the embedded file is a stream") }; + assert_eq!(stream.dict.get(b"Subtype"), Some(&Object::Name(b"text/csv".to_vec()))); + let params = stream.dict.get(b"Params").and_then(Object::as_dict).unwrap(); + assert_eq!(params.get(b"Size"), Some(&Object::Int(8))); + assert_eq!(stream.decoded().unwrap(), b"a,b\n1,2\n"); + // Deleting: the one named goes, the other stays; the last one takes the tree with it. + let mut doc = doc; + delete_attachment(&mut doc, "invoice.csv").unwrap(); + assert_eq!(attachment_keys(&doc), ["invoice.csv (2)"]); + assert_eq!(delete_attachment(&mut doc, "invoice.csv"), Err(OrganizeError::Invalid("there is no attachment named invoice.csv".into()))); + delete_attachment(&mut doc, "invoice.csv (2)").unwrap(); + let doc = save_and_reopen(&doc); + assert!(attachment_keys(&doc).is_empty()); + assert!(doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Names").is_none()); +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 7d7b6b9c58..75dd719594 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -185,6 +185,7 @@ impl PdfCraftApp { self.protect_draft = Default::default(); self.dialog = Some(Dialog::Protect); } + "edit.attach" => self.attach_file_dialog(), "protect.remove" => { if self.apply_edit(Edit::RemoveProtection) { self.notify_tr("Security will be removed when you save"); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 222865d72b..0b2b8eed60 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2373,3 +2373,8 @@ animation effects Off معطّلة This document is trying to open a local file: يحاول هذا المستند فتح ملف محلي: Move tag earlier نقل العلامة للأعلى Move tag later نقل العلامة للأسفل + Attach a file… إرفاق ملف… + Delete attachment حذف المرفق + Attaching files is not available on the web yet إرفاق الملفات غير متاح على الويب بعد + Attach {name} إرفاق {name} + Delete attachment {key} حذف المرفق {key} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 17a0890fa5..73ae509bc1 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2315,3 +2315,8 @@ animation effects Off Изключени This document is trying to open a local file: Този документ се опитва да отвори локален файл: Move tag earlier Преместване на етикета по-рано Move tag later Преместване на етикета по-късно + Attach a file… Прикачване на файл… + Delete attachment Изтриване на прикачения файл + Attaching files is not available on the web yet Прикачването на файлове още не е налично в уеб версията + Attach {name} Прикачване на {name} + Delete attachment {key} Изтриване на прикачения файл {key} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e6e29c8bba..5d78d54720 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2426,3 +2426,8 @@ animation effects Off Vypnuto This document is trying to open a local file: Tento dokument se pokouší otevřít místní soubor: Move tag earlier Přesunout značku dříve Move tag later Přesunout značku později + Attach a file… Připojit soubor… + Delete attachment Odstranit přílohu + Attaching files is not available on the web yet Připojování souborů zatím na webu není k dispozici + Attach {name} Připojit {name} + Delete attachment {key} Odstranit přílohu {key} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 38ef665d13..b75f4913af 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2353,3 +2353,8 @@ animation effects Off Aus This document is trying to open a local file: Dieses Dokument versucht, eine lokale Datei zu öffnen: Move tag earlier Tag früher verschieben Move tag later Tag später verschieben + Attach a file… Datei anhängen… + Delete attachment Anhang löschen + Attaching files is not available on the web yet Dateien anhängen ist im Web noch nicht verfügbar + Attach {name} {name} anhängen + Delete attachment {key} Anhang {key} löschen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 106278f012..b66a1bc6d2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2362,3 +2362,8 @@ animation effects Off Ανενεργά This document is trying to open a local file: Αυτό το έγγραφο προσπαθεί να ανοίξει ένα τοπικό αρχείο: Move tag earlier Μετακίνηση ετικέτας νωρίτερα Move tag later Μετακίνηση ετικέτας αργότερα + Attach a file… Επισύναψη αρχείου… + Delete attachment Διαγραφή συνημμένου + Attaching files is not available on the web yet Η επισύναψη αρχείων δεν είναι ακόμη διαθέσιμη στον ιστό + Attach {name} Επισύναψη {name} + Delete attachment {key} Διαγραφή συνημμένου {key} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5afbe4f2ef..43bbbbc35b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2482,3 +2482,8 @@ animation effects Off Desactivados This document is trying to open a local file: Este documento intenta abrir un archivo local: Move tag earlier Mover etiqueta antes Move tag later Mover etiqueta después + Attach a file… Adjuntar un archivo… + Delete attachment Eliminar adjunto + Attaching files is not available on the web yet Adjuntar archivos aún no está disponible en la web + Attach {name} Adjuntar {name} + Delete attachment {key} Eliminar adjunto {key} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 79389837d3..c2c3beec26 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2352,3 +2352,8 @@ animation effects Off Désactivés This document is trying to open a local file: Ce document tente d’ouvrir un fichier local : Move tag earlier Déplacer la balise plus tôt Move tag later Déplacer la balise plus tard + Attach a file… Joindre un fichier… + Delete attachment Supprimer la pièce jointe + Attaching files is not available on the web yet Joindre des fichiers n'est pas encore disponible sur le web + Attach {name} Joindre {name} + Delete attachment {key} Supprimer la pièce jointe {key} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3d6e1e3af5..9d215c571a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2351,3 +2351,8 @@ animation effects Off Ki This document is trying to open a local file: Ez a dokumentum egy helyi fájlt próbál megnyitni: Move tag earlier Címke korábbra helyezése Move tag later Címke későbbre helyezése + Attach a file… Fájl csatolása… + Delete attachment Melléklet törlése + Attaching files is not available on the web yet Fájlok csatolása a weben még nem érhető el + Attach {name} {name} csatolása + Delete attachment {key} {key} melléklet törlése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3575ab374d..282db62fcc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2361,3 +2361,8 @@ animation effects Off Disattivati This document is trying to open a local file: Questo documento sta tentando di aprire un file locale: Move tag earlier Sposta tag prima Move tag later Sposta tag dopo + Attach a file… Allega un file… + Delete attachment Elimina allegato + Attaching files is not available on the web yet Allegare file non è ancora disponibile sul web + Attach {name} Allega {name} + Delete attachment {key} Elimina allegato {key} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5e61802c35..a33b764e7d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2352,3 +2352,8 @@ animation effects Off オフ This document is trying to open a local file: この文書はローカルファイルを開こうとしています: Move tag earlier タグを前へ移動 Move tag later タグを後へ移動 + Attach a file… ファイルを添付… + Delete attachment 添付ファイルを削除 + Attaching files is not available on the web yet Web 版ではまだファイルを添付できません + Attach {name} {name} を添付 + Delete attachment {key} 添付ファイル {key} を削除 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 8e4a090403..5d76591339 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2350,3 +2350,8 @@ animation effects Off Өшірулі This document is trying to open a local file: Бұл құжат жергілікті файлды ашуға әрекеттеніп жатыр: Move tag earlier Тегті ертерек жылжыту Move tag later Тегті кейінірек жылжыту + Attach a file… Файл тіркеу… + Delete attachment Тіркемені жою + Attaching files is not available on the web yet Вебте файл тіркеу әзірге қолжетімсіз + Attach {name} {name} тіркеу + Delete attachment {key} {key} тіркемесін жою diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index ae61e396ff..410bd7c7b5 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2596,3 +2596,8 @@ key Space Tarpas This document is trying to open a local file: Šis dokumentas bando atverti vietinį failą: Move tag earlier Perkelti žymą anksčiau Move tag later Perkelti žymą vėliau + Attach a file… Pridėti failą… + Delete attachment Ištrinti priedą + Attaching files is not available on the web yet Failų pridėjimas žiniatinklyje dar negalimas + Attach {name} Pridėti {name} + Delete attachment {key} Ištrinti priedą {key} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 310a990f7d..395b905033 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2332,3 +2332,8 @@ animation effects Off Wyłączone This document is trying to open a local file: Ten dokument próbuje otworzyć plik lokalny: Move tag earlier Przenieś znacznik wcześniej Move tag later Przenieś znacznik później + Attach a file… Dołącz plik… + Delete attachment Usuń załącznik + Attaching files is not available on the web yet Dołączanie plików nie jest jeszcze dostępne w wersji webowej + Attach {name} Dołącz {name} + Delete attachment {key} Usuń załącznik {key} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 623d69ce80..cd6da1c53c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2366,3 +2366,8 @@ animation effects Off Desativados This document is trying to open a local file: Este documento está tentando abrir um arquivo local: Move tag earlier Mover tag para antes Move tag later Mover tag para depois + Attach a file… Anexar um arquivo… + Delete attachment Excluir anexo + Attaching files is not available on the web yet Anexar arquivos ainda não está disponível na web + Attach {name} Anexar {name} + Delete attachment {key} Excluir anexo {key} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7f628088d6..cc7c4d2b24 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2265,3 +2265,8 @@ animation effects Off Выкл This document is trying to open a local file: Этот документ пытается открыть локальный файл: Move tag earlier Переместить тег раньше Move tag later Переместить тег позже + Attach a file… Прикрепить файл… + Delete attachment Удалить вложение + Attaching files is not available on the web yet Прикрепление файлов в веб-версии пока недоступно + Attach {name} Прикрепить {name} + Delete attachment {key} Удалить вложение {key} diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 3feaad354d..1ef69378f1 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2357,3 +2357,8 @@ animation effects Off Izklopljeni This document is trying to open a local file: Ta dokument poskuša odpreti lokalno datoteko: Move tag earlier Premakni oznako prej Move tag later Premakni oznako pozneje + Attach a file… Priloži datoteko… + Delete attachment Izbriši prilogo + Attaching files is not available on the web yet Prilaganje datotek na spletu še ni na voljo + Attach {name} Priloži {name} + Delete attachment {key} Izbriši prilogo {key} diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index d15365f3d4..4ef063f9b6 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2265,3 +2265,8 @@ animation effects Off ఆఫ్ This document is trying to open a local file: ఈ పత్రం ఒక స్థానిక ఫైల్‌ను తెరవడానికి ప్రయత్నిస్తోంది: Move tag earlier ట్యాగ్‌ను ముందుకు తరలించు Move tag later ట్యాగ్‌ను వెనుకకు తరలించు + Attach a file… ఫైల్‌ను జోడించు… + Delete attachment జోడింపును తొలగించు + Attaching files is not available on the web yet వెబ్‌లో ఫైల్‌లను జోడించడం ఇంకా అందుబాటులో లేదు + Attach {name} {name}ను జోడించు + Delete attachment {key} జోడింపు {key}ను తొలగించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 44fd05f074..eed722a897 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2348,3 +2348,8 @@ animation effects Off Kapalı This document is trying to open a local file: Bu belge yerel bir dosya açmaya çalışıyor: Move tag earlier Etiketi daha erken taşı Move tag later Etiketi daha geç taşı + Attach a file… Dosya ekle… + Delete attachment Eki sil + Attaching files is not available on the web yet Dosya eklemek web'de henüz kullanılamıyor + Attach {name} {name} ekle + Delete attachment {key} {key} ekini sil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 648dc855fe..c77239d528 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2374,3 +2374,8 @@ animation effects Off Вимкнено This document is trying to open a local file: Цей документ намагається відкрити локальний файл: Move tag earlier Перемістити тег раніше Move tag later Перемістити тег пізніше + Attach a file… Прикріпити файл… + Delete attachment Видалити вкладення + Attaching files is not available on the web yet Прикріплення файлів у веб-версії поки недоступне + Attach {name} Прикріпити {name} + Delete attachment {key} Видалити вкладення {key} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 391c8eda7c..df6b067e72 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2316,3 +2316,8 @@ animation effects Off 关 This document is trying to open a local file: 此文档正尝试打开本地文件: Move tag earlier 将标签前移 Move tag later 将标签后移 + Attach a file… 附加文件… + Delete attachment 删除附件 + Attaching files is not available on the web yet 网页版尚不支持附加文件 + Attach {name} 附加 {name} + Delete attachment {key} 删除附件 {key} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index da45921c21..db8ebd1569 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2226,3 +2226,8 @@ animation effects Off 關 This document is trying to open a local file: 此文件正嘗試開啟本機檔案: Move tag earlier 將標記前移 Move tag later 將標記後移 + Attach a file… 附加檔案… + Delete attachment 刪除附件 + Attaching files is not available on the web yet 網頁版尚不支援附加檔案 + Attach {name} 附加 {name} + Delete attachment {key} 刪除附件 {key} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ea3da00957..9e952b1c33 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1440,6 +1440,38 @@ impl PdfCraftApp { } /// Save an attachment to disk, or open a PDF attachment in a new tab. + /// Edit ▸ Attach a file…: choose a file, then attach it to the active document and show + /// the Attachments panel with it. + pub fn attach_file_dialog(&mut self) { + if self.active_ids().is_none() { + return; + } + #[cfg(not(target_arch = "wasm32"))] + { + let dialog = rfd::AsyncFileDialog::new().set_title(tl!("Attach a file").to_string()); + self.ask_one(pickers::Ask::File(dialog), None, |app, path| app.attach_file(&path)); + } + #[cfg(target_arch = "wasm32")] + self.notify_tr("Attaching files is not available on the web yet"); + } + + /// Attach the file at `path` to the active document (document level: the Attachments + /// panel, not a paper-clip comment on a page). + pub fn attach_file(&mut self, path: &std::path::Path) { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) => { + self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]); + return; + } + }; + let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string); + if self.apply_edit(pdfcraft_engine::Edit::AddAttachment { name, bytes, description: None, mime }) { + self.choose_right_panel(Some(RightPanel::Attachments)); + } + } + pub fn attachment_action(&mut self, doc: DocId, index: usize, open: bool) { let Some(d) = self.session.get(doc) else { return }; let Some(att) = d.info.attachments.get(index).cloned() else { return }; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bdd625804d..66d8fa07a4 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -680,6 +680,14 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { RightPanel::Search => crate::search_ui::panel(ui, &t, view, info.pages.len()), RightPanel::Compare => compare_action = crate::compare_ui::panel(ui, &t, compare, id), RightPanel::Attachments => { + let attachable = doc.allows_modification() && doc.read_only_reason.is_none(); + if attachable { + egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 6)).show(ui, |ui| { + if ui.add(egui::Button::new(tl!("Attach a file…"))).clicked() { + panel_command = Some("edit.attach"); + } + }); + } if info.attachments.is_empty() { empty(ui, &t, "paperclip", "This document has no attachments."); } @@ -712,6 +720,12 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { { attachment_action = Some((ai, true)); } + if let pdfcraft_render::AttachmentSource::Document { key } = &a.source + && attachable + && icons::button(ui, "trash-2", 28.0, false, tl!("Delete attachment")).clicked() + { + panel_edit = Some(pdfcraft_engine::Edit::DeleteAttachment { key: key.clone() }); + } }); }); }); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8f4194b932..e3d02b6718 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3325,11 +3325,11 @@ title = "Add file attachments" area = "D" tier = "P2" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["edit.attach"] +tools = ["attachment_add"] +evidence = ["crates/organize/src/tests.rs::attachments_are_added_listed_and_deleted", "crates/organize/src/tests.rs::attachments_added_in_the_app_survive_combine_and_more_can_be_attached", "crates/automation/tests/automation.rs::attaching_and_deleting_files_through_the_tools"] +notes = "Edit ▸ Attach a file… (also the Attach a file… button in the Attachments panel and the Edit a PDF list): the file goes into /Names /EmbeddedFiles as a Filespec with a Flate-compressed EmbeddedFile stream (/Size, /ModDate, /Subtype from the extension); a name already taken gets \" [[feature]] id = "organize.attachments-delete" @@ -3337,11 +3337,11 @@ title = "Delete attachments" area = "D" tier = "P2" milestone = "M12" -status = "planned" +status = "shipped" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["attachment_delete"] +evidence = ["crates/organize/src/tests.rs::attachments_are_added_listed_and_deleted", "crates/organize/src/tests.rs::attachments_added_in_the_app_survive_combine_and_more_can_be_attached", "crates/automation/tests/automation.rs::attaching_and_deleting_files_through_the_tools"] +notes = "The trash button on a document-level row of the Attachments panel; the last one removed takes the /EmbeddedFiles tree with it. Undoable. A paper-clip comment is deleted as a comment." [[feature]] id = "organize.attachments-description"