Attachments: attach a file and delete one (panel, edit, tools); duplicate names numbered as text

pdfcraft_organize::add_attachment puts a file in /Names /EmbeddedFiles as a
Filespec with a Flate-compressed EmbeddedFile stream (/Subtype, /Size,
/ModDate), the name made unique; delete_attachment removes one, the last taking
the tree with it. Edit::AddAttachment / Edit::DeleteAttachment (undoable),
Edit ▸ Attach a file… with a button at the top of the Attachments panel and a
trash button on each document-level row, tools attachment_add and
attachment_delete, rows in every language, parity attachments-add and
attachments-delete shipped.

Fixes set_attachments (Combine): a duplicate name was numbered by appending
ASCII bytes, corrupting UTF-16 keys; names are now numbered as text. An
indirect /Names is updated in place instead of replaced, so its /Dests and
/JavaScript stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Osvaldo Duran
2026-10-13 14:08:39 -04:00
co-authored by Claude Opus 5.5
parent 7437bde378
commit f3b6864bcc
35 changed files with 511 additions and 16 deletions
+18
View File
@@ -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<String> = 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)?,
+6
View File
@@ -129,6 +129,12 @@ pub fn tools() -> Vec<ToolDef> {
"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"])),
+40
View File
@@ -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<String> = 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");
}
+1 -1
View File
@@ -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),
],
},
],
+1
View File
@@ -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"),
+23 -1
View File
@@ -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<InitialView>),
/// 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<u8>,
description: Option<String>,
mime: Option<String>,
},
/// 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())?;
}
+159
View File
@@ -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<String> {
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<String, OrganizeError> {
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<String> = 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<u8>, 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,
})
}
+19 -5
View File
@@ -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<u8>, Object)>) {
pub(crate) fn name_tree_entries(src: &Document, node: &Object, depth: u8, out: &mut Vec<(Vec<u8>, Object)>) {
if depth > 32 {
return;
}
@@ -1075,18 +1075,24 @@ fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec<u8
}
/// Write attachments as a flat, sorted `/EmbeddedFiles` name tree; duplicate names get " (2)"…
fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec<u8>, Object)>) -> Result<(), OrganizeError> {
pub(crate) fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec<u8>, 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<u8>, 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(),
+2
View File
@@ -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::{
+81
View File
@@ -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<u8>, Option<String>) {
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());
}
+1
View File
@@ -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");
+5
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 8 and column 16.
+5
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 51.
+5
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 467 and column 9.
+5
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 60.
+5
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 39.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 44.
+5
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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} を削除
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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} тіркемесін жою
Can't render this file because it contains an unexpected character in line 814 and column 9.
+5
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 393 and column 9.
+5
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 9 and column 16.
+5
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 9 and column 16.
+5
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 9 and column 16.
+5
View File
@@ -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}ను తొలగించు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 813 and column 9.
+5
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 9 and column 16.
+5
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 9 and column 16.
+32
View File
@@ -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 };
+14
View File
@@ -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() });
}
});
});
});
+9 -9
View File
@@ -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"