M12: edit attachment descriptions

This commit is contained in:
Harrison Booth committed 2026-10-30 20:15:49 -05:00
1 parent ec33924e80
commit ab433eb763
19 files changed
+220 -23

No files matched your search

+4
View File
@@ -704,6 +704,10 @@ impl Automation {
let edit = Edit::DeleteAttachment { key: a.str("name")?.into() };
self.apply(&a, edit)?
}
"attachment_edit" => {
let edit = Edit::EditAttachmentDescription { key: a.str("name")?.into(), description: Some(a.str("description")?.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)?,
+2
View File
@@ -138,6 +138,8 @@ pub fn tools() -> Vec<ToolDef> {
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("attachment_edit", "Edit an attachment description", "Change the description of the document-level attachment with this name (as doc_info lists it). An empty description clears it. The file and other attachment data are preserved. Undoable.")
.with(schema(json!({"doc": doc(), "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "name", "description"])),
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"])),
+29
View File
@@ -9324,6 +9324,35 @@ fn attaching_and_deleting_files_through_the_tools() {
assert!(a.call("attachment_add", &json!({ "doc": doc, "path": "missing.txt" })).is_err(), "a file that is not there is refused");
}
#[test]
fn attachment_descriptions_can_be_edited_cleared_undone_and_saved() {
let dir = workdir("attachment-description");
let bytes = b"original attachment bytes";
std::fs::write(dir.join("notes.txt"), bytes).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap();
ok(&mut a, "attachment_add", json!({"doc": doc, "path": "notes.txt", "description": "Original"}));
ok(&mut a, "attachment_edit", json!({"doc": doc, "name": "notes.txt", "description": "Updated"}));
assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Updated");
ok(&mut a, "edit_undo", json!({"doc": doc}));
assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Original");
ok(&mut a, "edit_redo", json!({"doc": doc}));
assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Updated");
ok(&mut a, "attachment_edit", json!({"doc": doc, "name": "notes.txt", "description": ""}));
assert!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"].is_null());
ok(&mut a, "edit_undo", json!({"doc": doc}));
ok(&mut a, "doc_save", json!({"doc": doc, "path": "edited.pdf", "full": true}));
let saved = std::fs::read(dir.join("edited.pdf")).unwrap();
let info = pdfcraft_render::inspect(std::sync::Arc::new(saved.clone()), None).unwrap();
let attachment = info.attachments.iter().find(|item| item.name == "notes.txt").unwrap();
assert_eq!(attachment.description.as_deref(), Some("Updated"));
assert_eq!(pdfcraft_render::attachment_data(&saved, None, attachment).unwrap(), bytes);
let reopened = ok(&mut a, "doc_open", json!({"path": "edited.pdf"}))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "doc_info", json!({"doc": reopened}))["attachments"][0]["description"], "Updated");
}
#[test]
fn fdf_nested_comment_values_survive_export_import_and_save_through_tools() {
use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full};
+5
View File
@@ -317,6 +317,11 @@ pub enum Edit {
DeleteAttachment {
key: String,
},
/// Change or clear only `/Desc` on a document-level attachment's file specification.
EditAttachmentDescription {
key: String,
description: Option<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
+2
View File
@@ -31,6 +31,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Insert pages from {name} — fields renamed",
"Attach {name}",
"Delete attachment {key}",
"Edit attachment description",
"Add bookmark",
"Rename bookmark",
"Delete bookmark",
@@ -233,6 +234,7 @@ impl Edit {
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::EditAttachmentDescription { .. } => Label::of("Edit attachment description"),
Edit::AutoTagText => Label::of("Automatically tag text"),
Edit::SetAltText { .. } => Label::of("Set alternate text"),
Edit::AutoTagFormFields => Label::of("Tag form fields"),
+4
View File
@@ -127,6 +127,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::SetInitialView(_)
| Edit::AddAttachment { .. }
| Edit::DeleteAttachment { .. }
| Edit::EditAttachmentDescription { .. }
| Edit::AutoTagText
| Edit::SetAltText { .. }
| Edit::AutoTagFormFields
@@ -360,6 +361,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
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::EditAttachmentDescription { key, description } => {
pdfcraft_organize::set_attachment_description(doc, key, description.as_deref())?;
}
Edit::ReplaceText { page, quads, text, author, strike, caret } => {
// One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by
// the strikeout's `/NM` (#2666).
+1 -1
View File
@@ -48,7 +48,7 @@ pub(crate) 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,
Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } | Edit::EditAttachmentDescription { .. } => Scope::Full,
// A file attachment also changes the Attachments list.
Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full,
Edit::AddMeasurement(_)
+32 -1
View File
@@ -7,7 +7,7 @@
use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream};
use crate::OrganizeError;
use crate::import::{name_tree_entries, set_attachments};
use crate::import::{name_tree_entries, set_attachments, set_attachments_exact};
/// The keys of the document-level attachments, in name-tree order.
pub fn attachment_keys(doc: &Document) -> Vec<String> {
@@ -82,6 +82,37 @@ pub fn delete_attachment(doc: &mut Document, key: &str) -> Result<(), OrganizeEr
if all.is_empty() { clear_embedded_files(doc) } else { set_attachments(doc, all) }
}
/// Set or clear the description of a document-level attachment without replacing its file
/// specification or embedded stream.
pub fn set_attachment_description(doc: &mut Document, key: &str, description: Option<&str>) -> Result<(), OrganizeError> {
let mut all = entries(doc);
let Some((_, value)) = all.iter_mut().find(|(name, _)| PdfString::literal(name.clone()).to_text() == key) else {
return Err(OrganizeError::Invalid(format!("there is no attachment named {key}")));
};
let description = description.map(str::trim).filter(|s| !s.is_empty()).map(PdfString::text).map(Object::String);
match value {
Object::Ref(reference) => {
doc.update_dict(*reference, |spec| match description {
Some(description) => spec.set(b"Desc".to_vec(), description),
None => {
spec.remove(b"Desc");
}
})?;
}
Object::Dict(spec) => {
match description {
Some(description) => spec.set(b"Desc".to_vec(), description),
None => {
spec.remove(b"Desc");
}
}
set_attachments_exact(doc, all)?;
}
_ => return Err(OrganizeError::Invalid(format!("attachment {key} has an invalid file specification"))),
}
Ok(())
}
/// The `/EmbeddedFiles` entries (key, file specification), in tree order.
fn entries(doc: &Document) -> Vec<(Vec<u8>, Object)> {
let Some(tree) = doc
+1 -1
View File
@@ -28,8 +28,8 @@ mod combine;
mod copy;
mod fields;
pub(crate) use carry::set_attachments;
use carry::{Mark, source_outline, write_outline_level};
pub(crate) use carry::{set_attachments, set_attachments_exact};
pub use combine::{
CombineOutcome, Combiner, Run, SplitBy, combine, combine_grouped, combine_grouped_with, combine_selected, combine_selected_with, combine_with,
extract_pages, split, split_ranges,
+25 -14
View File
@@ -23,29 +23,40 @@ pub(super) fn collect_attachments(dst: &mut Document, src: &Document, out: &mut
/// Write attachments as a flat, sorted `/EmbeddedFiles` name tree; duplicate names get " (2)"…
pub(crate) fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec<u8>, Object)>) -> Result<(), OrganizeError> {
write_attachments(dst, &mut entries, true)
}
/// Rewrite the name tree without changing attachment keys (used when editing one file spec).
pub(crate) fn set_attachments_exact(dst: &mut Document, mut entries: Vec<(Vec<u8>, Object)>) -> Result<(), OrganizeError> {
write_attachments(dst, &mut entries, false)
}
fn write_attachments(dst: &mut Document, entries: &mut [(Vec<u8>, Object)], uniquify: bool) -> 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 = pdfcraft_cos::PdfString::literal(k.clone()).to_text();
let mut text = base.clone();
let mut n = 2;
while !seen.insert(text.clone()) {
text = format!("{base} ({n})");
n += 1;
}
if text != base {
*k = pdfcraft_cos::PdfString::text(&text).bytes;
if uniquify {
let mut seen = std::collections::HashSet::new();
for (k, _) in entries.iter_mut() {
let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text();
let mut text = base.clone();
let mut n = 2;
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();
for (k, v) in entries {
names.push(Object::String(pdfcraft_cos::PdfString::literal(k)));
names.push(v);
for (k, v) in entries.iter() {
names.push(Object::String(pdfcraft_cos::PdfString::literal(k.clone())));
names.push(v.clone());
}
let mut tree = Dict::new();
tree.set(b"Names".to_vec(), Object::Array(names));
+1 -1
View File
@@ -26,7 +26,7 @@ mod transition;
pub mod view;
mod xmp;
pub use attachments::{add_attachment, attachment_keys, delete_attachment, mime_for_name};
pub use attachments::{add_attachment, attachment_keys, delete_attachment, mime_for_name, set_attachment_description};
pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box};
pub use comments::import_comments;
pub use dedupe::dedupe_resources;
+37
View File
@@ -3440,6 +3440,43 @@ fn attachments_are_added_listed_and_deleted() {
assert!(doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Names").is_none());
}
#[test]
fn attachment_description_changes_only_the_file_spec_and_can_be_cleared() {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
let key = add_attachment(&mut doc, "notes.txt", b"keep these bytes", Some("Original"), Some("text/plain"), None).unwrap();
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 resolved_pairs = doc.resolve(&pairs);
let pairs = resolved_pairs.as_array().unwrap();
let spec_ref = pairs.get(1).and_then(Object::as_ref).unwrap();
let stream_ref = doc.get(spec_ref).as_dict().unwrap().get(b"EF").and_then(Object::as_dict).unwrap().reference(b"F").unwrap();
doc.update_dict(spec_ref, |spec| spec.set(b"CustomKey".to_vec(), Object::name("preserved"))).unwrap();
set_attachment_description(&mut doc, &key, Some("Revised description")).unwrap();
let spec_object = doc.get(spec_ref);
let spec = spec_object.as_dict().unwrap();
assert_eq!(spec.get(b"Desc").and_then(Object::as_string).map(pdfcraft_cos::PdfString::to_text).as_deref(), Some("Revised description"));
assert_eq!(spec.get(b"UF").and_then(Object::as_string).map(pdfcraft_cos::PdfString::to_text).as_deref(), Some("notes.txt"));
assert_eq!(spec.get(b"CustomKey"), Some(&Object::name("preserved")));
assert_eq!(spec.get(b"EF").and_then(Object::as_dict).and_then(|ef| ef.reference(b"F")), Some(stream_ref));
let stream_object = doc.get(stream_ref);
let pdfcraft_cos::Object::Stream(stream) = stream_object.as_ref() else { panic!("the attachment file remains a stream") };
assert_eq!(stream.decoded().unwrap(), b"keep these bytes");
assert_eq!(attachment_keys(&doc), ["notes.txt"]);
set_attachment_description(&mut doc, &key, Some(" ")).unwrap();
assert!(!doc.get(spec_ref).as_dict().unwrap().contains(b"Desc"));
let reopened = save_and_reopen(&doc);
assert_eq!(attachment_keys(&reopened), ["notes.txt"]);
assert_eq!(
set_attachment_description(&mut doc, "missing.txt", Some("x")),
Err(OrganizeError::Invalid("there is no attachment named missing.txt".into()))
);
}
#[test]
fn rotate_repeated_indices_accumulate_from_the_current_page_value() {
for level in 0..3 {
+1
View File
@@ -165,6 +165,7 @@ impl PdfCraftApp {
pending_overwrite: None,
save_override: None,
props_draft: None,
attachment_description_draft: None,
view_draft: None,
custom_draft: None,
requests: Default::default(),
+41
View File
@@ -54,6 +54,7 @@ struct Pending {
optimize_now: bool,
duplicate_now: Option<Edit>,
page_props_now: Option<(pdfcraft_engine::DocId, u64, Edit)>,
attachment_description_now: Option<(pdfcraft_engine::DocId, Edit)>,
replace_now: bool,
open_revision: Option<usize>,
a11y_now: bool,
@@ -334,6 +335,24 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m
req.close = req.apply || cancel;
return false;
}
Dialog::AttachmentDescription => {
let Some((doc, _, _)) = app.attachment_description_draft.as_ref() else {
req.close = true;
return false;
};
if app.active_ids().is_none_or(|(_, active)| active != *doc) {
req.close = true;
return false;
}
let Some((_, key, description)) = app.attachment_description_draft.as_mut() else {
req.close = true;
return false;
};
ui.heading(tl!("Edit attachment description"));
ui.label(key.as_str());
let label = ui.label(tl!("Description"));
ui.add(widgets::line(description)).labelled_by(label.id);
}
Dialog::Signature => {
if !dialog_signature(ui, app, req) {
return false;
@@ -1585,6 +1604,17 @@ fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req:
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
req.close = true;
}
} else if dialog == Dialog::AttachmentDescription {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
if let Some((doc, key, description)) = app.attachment_description_draft.as_ref() {
req.attachment_description_now =
Some((*doc, Edit::EditAttachmentDescription { key: key.clone(), description: Some(description.clone()) }));
}
req.close = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
req.close = true;
}
} else if changed {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
req.apply = true;
@@ -1660,6 +1690,14 @@ fn finish_requests(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, re
/// Runs the document actions the dialog asked for.
fn finish_actions(app: &mut PdfCraftApp, req: &mut Pending) {
if let Some((doc, edit)) = req.attachment_description_now.take() {
app.attachment_description_draft = None;
if app.active_ids().is_some_and(|(_, active)| active == doc) {
app.apply_edit(edit);
} else {
app.notify_tr("The document changed while the dialog was open, so nothing was changed. Open the description editor again.");
}
}
if req.summarize_now {
app.summarize_comments();
}
@@ -1837,6 +1875,9 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req:
let replaces = req.link_command.is_some_and(|c| c.starts_with("protect.")) || req.open_revision.is_some();
if modal_closed || req.close || replaces {
app.dialog = None;
if dialog == Dialog::AttachmentDescription {
app.attachment_description_draft = None;
}
app.props_draft = None;
app.view_draft = None;
app.custom_draft = None;
+2
View File
@@ -432,6 +432,8 @@ pub struct PdfCraftApp {
pub save_override: Option<String>,
/// Document Properties ▸ Description fields being edited: (document, Title/Author/Subject/Keywords).
pub props_draft: Option<(DocId, [String; 4])>,
/// Attachment description being edited: (document, name-tree key, description).
pub attachment_description_draft: Option<(DocId, String, String)>,
/// Document Properties ▸ Initial View (and reading options) being edited.
pub view_draft: Option<(DocId, pdfcraft_engine::InitialView)>,
/// Document Properties ▸ Custom being edited.
+7
View File
@@ -361,6 +361,13 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
attachment_action = Some((ai, false));
ui.close();
}
if let Some(key) = deletable
&& ui.button(tl!("Edit description")).clicked()
{
app.attachment_description_draft = Some((id, key.clone(), a.description.clone().unwrap_or_default()));
app.dialog = Some(crate::Dialog::AttachmentDescription);
ui.close();
}
if let Some(key) = deletable
&& ui.button(tl!("Delete attachment")).clicked()
{
+2
View File
@@ -125,6 +125,8 @@ pub enum Dialog {
Signature,
/// Comment Properties.
CommentProps,
/// Edit the description of an embedded document attachment.
AttachmentDescription,
/// Replace Pages (after choosing the file).
ReplacePages,
/// Prepare a form ▸ Field Properties.
+20 -1
View File
@@ -22,7 +22,7 @@ fn attachments_pdf() -> Vec<u8> {
[format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", INNER.len()).as_bytes(), INNER, b"\nendstream"].concat(),
b"<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 4 0 R >> >>".to_vec(),
b"<< /Type /EmbeddedFile /Length 11 >>\nstream\nhello notes\nendstream".to_vec(),
b"<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 6 0 R >> >>".to_vec(),
b"<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /Desc (Original) /EF << /F 6 0 R >> >>".to_vec(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
@@ -151,6 +151,25 @@ fn the_right_click_menu_opens_saves_and_deletes() {
assert!(doc.can_undo().is_some_and(|l| l.starts_with("Delete attachment")), "as one undoable edit");
}
#[test]
fn the_description_can_be_edited_from_the_row_menu() {
let mut h = harness();
right_click(&mut h, "notes.txt");
h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Edit description").click();
h.run_steps(2);
let description = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Description");
description.click();
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
description.type_text("Edited in the panel");
h.get_by_label("OK").click();
h.run_steps(3);
let state = h.state();
let doc = state.session.get(state.views[0].id).unwrap();
let attachment = doc.info.attachments.iter().find(|a| a.name == "notes.txt").unwrap();
assert_eq!(attachment.description.as_deref(), Some("Edited in the panel"));
assert!(doc.can_undo().is_some_and(|label| label == "Edit attachment description"));
}
#[test]
fn the_row_buttons_still_take_their_own_clicks() {
let dir = Dir::new("buttons");
+4 -4
View File
@@ -3420,11 +3420,11 @@ title = "Edit attachment descriptions"
area = "D"
tier = "P2"
milestone = "M12"
status = "planned"
status = "shipped"
commands = []
tools = []
evidence = []
notes = ""
tools = ["attachment_edit"]
evidence = ["crates/organize/src/tests.rs::attachment_description_changes_only_the_file_spec_and_can_be_cleared", "crates/automation/tests/it/automation.rs::attachment_descriptions_can_be_edited_cleared_undone_and_saved", "crates/ui-egui/tests/it/attachment_row.rs::the_description_can_be_edited_from_the_row_menu"]
notes = "The document-level attachment row menu edits only the Filespec /Desc; an empty description clears it. The embedded stream and other file-spec keys stay intact. Undo/redo and the headless attachment_edit tool use the same edit. Page paper-clip comments are not affected."
[[feature]]
id = "organize.associated-files"