mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 23:32:08 +00:00
Merge #5508: M12: edit attachment descriptions
# Conflicts: # crates/automation/src/dispatch.rs # crates/automation/src/json.rs # crates/automation/src/tools.rs # crates/engine/src/edit.rs # crates/engine/src/edit/labels.rs # crates/engine/src/edit/run.rs # crates/organize/src/import/carry.rs # crates/ui-egui/src/dialogs.rs
This commit is contained in:
@@ -768,6 +768,48 @@ impl Automation {
|
||||
self.apply(&a, edit)?
|
||||
}
|
||||
"doc_convert_colours" => self.convert_colours(&a)?,
|
||||
"attachment_edit" => {
|
||||
let key = a.opt_str("key")?;
|
||||
let name = a.opt_str("name")?;
|
||||
if key.is_some() == name.is_some() {
|
||||
return Err(ToolError::InvalidArgs("provide exactly one of key or name".into()));
|
||||
}
|
||||
let keys: Vec<String> = self
|
||||
.doc(&a)?
|
||||
.info
|
||||
.attachments
|
||||
.iter()
|
||||
.filter_map(|attachment| match &attachment.source {
|
||||
pdfcraft_render::AttachmentSource::Document { key: attachment_key }
|
||||
if key.is_some_and(|key| key == attachment_key) || name.is_some_and(|name| name == attachment.name) =>
|
||||
{
|
||||
Some(attachment_key.clone())
|
||||
}
|
||||
pdfcraft_render::AttachmentSource::Document { .. } => None,
|
||||
pdfcraft_render::AttachmentSource::Annotation { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
let key = match keys.as_slice() {
|
||||
[key] => key.clone(),
|
||||
[] if key.is_some() => {
|
||||
return Err(ToolError::InvalidArgs(format!("there is no document attachment with key {:?}", key.unwrap_or_default())));
|
||||
}
|
||||
[] => {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"there is no document attachment named {:?}; use a name from doc_info",
|
||||
name.unwrap_or_default()
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"more than one document attachment is named {:?}; provide its key from doc_info",
|
||||
name.unwrap_or_default()
|
||||
)));
|
||||
}
|
||||
};
|
||||
let edit = Edit::EditAttachmentDescription { key, 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)?,
|
||||
|
||||
@@ -341,7 +341,7 @@ pub(crate) fn info(d: &Document) -> Value {
|
||||
"horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque,
|
||||
"display": t.display,
|
||||
})).collect::<Vec<_>>(),
|
||||
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
|
||||
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "key": match &a.source { pdfcraft_render::AttachmentSource::Document { key } => Some(key), pdfcraft_render::AttachmentSource::Annotation { .. } => None }, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
|
||||
// A PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): its pages are a cover sheet and its
|
||||
// documents are `attachments`; `initial` names the one presented first, null for the cover.
|
||||
"portfolio": i.portfolio.as_ref().map(|p| json!({
|
||||
|
||||
@@ -181,6 +181,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("attachment_edit", "Edit an attachment description", "Change the description of a document attachment by its exact key from doc_info, or by a unique displayed name. An empty description clears it. The file and other attachment data are preserved. Undoable.")
|
||||
.with(schema(json!({"doc": doc(), "key": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "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"])),
|
||||
|
||||
@@ -11380,6 +11380,80 @@ 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": "Résumé 東京"}));
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["attachments"][0]["description"], "Résumé 東京");
|
||||
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"], "Résumé 東京");
|
||||
|
||||
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("Résumé 東京"));
|
||||
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"], "Résumé 東京");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_edit_resolves_the_listed_name_to_its_name_tree_key() {
|
||||
let dir = workdir("attachment-name-tree-key");
|
||||
std::fs::write(dir.join("payload.txt"), b"payload").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":"payload.txt","name":"storage-key.txt"}));
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"seed.pdf","full":true}));
|
||||
let mut source = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("seed.pdf")).unwrap())).unwrap();
|
||||
let key = "storage-key.txt";
|
||||
let root = source.root().unwrap();
|
||||
let catalog = source.get(root).as_dict().unwrap().clone();
|
||||
let names = source.resolve(catalog.get(b"Names").unwrap()).as_dict().unwrap().clone();
|
||||
let tree = source.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().unwrap().clone();
|
||||
let pairs = source.resolve(tree.get(b"Names").unwrap()).as_array().unwrap().clone();
|
||||
let spec_ref = pairs.get(1).and_then(pdfcraft_cos::Object::as_ref).unwrap();
|
||||
source
|
||||
.update_dict(spec_ref, |spec| {
|
||||
let name = pdfcraft_cos::Object::String(pdfcraft_cos::PdfString::text("visible-name.txt"));
|
||||
spec.set(b"F".to_vec(), name.clone());
|
||||
spec.set(b"UF".to_vec(), name);
|
||||
})
|
||||
.unwrap();
|
||||
let bytes = pdfcraft_cos::write_full(&source, &pdfcraft_cos::SaveOptions::default()).unwrap();
|
||||
std::fs::write(dir.join("named.pdf"), bytes).unwrap();
|
||||
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"named.pdf"}))["doc"].as_u64().unwrap();
|
||||
let attachment = &ok(&mut a, "doc_info", json!({"doc":doc}))["attachments"][0];
|
||||
assert_eq!(attachment["name"], "visible-name.txt");
|
||||
assert_eq!(attachment["key"], key);
|
||||
ok(&mut a, "attachment_edit", json!({"doc":doc,"key":key,"description":"Updated"}));
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["attachments"][0]["description"], "Updated");
|
||||
assert!(matches!(
|
||||
a.call("attachment_edit", &json!({"doc":doc,"name":"missing.txt","description":"x"})),
|
||||
Err(ToolError::InvalidArgs(message)) if message.contains("no document attachment named")
|
||||
));
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"edited.pdf","full":true}));
|
||||
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("edited.pdf")).unwrap())).unwrap();
|
||||
let catalog = saved.get(saved.root().unwrap()).as_dict().unwrap().clone();
|
||||
let names = saved.resolve(catalog.get(b"Names").unwrap()).as_dict().unwrap().clone();
|
||||
let tree = saved.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().unwrap().clone();
|
||||
let pairs = saved.resolve(tree.get(b"Names").unwrap()).as_array().unwrap().clone();
|
||||
assert_eq!(pairs.first().and_then(pdfcraft_cos::Object::as_string).map(pdfcraft_cos::PdfString::to_text).as_deref(), Some(key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_nested_comment_values_survive_export_import_and_save_through_tools() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full};
|
||||
|
||||
@@ -380,6 +380,11 @@ pub enum Edit {
|
||||
pages: Vec<usize>,
|
||||
settings: pdfcraft_edit::ConvertColours,
|
||||
},
|
||||
/// 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
|
||||
|
||||
@@ -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",
|
||||
@@ -254,6 +255,7 @@ impl Edit {
|
||||
Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()),
|
||||
Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()),
|
||||
Edit::ConvertColours { .. } => Label::of("Convert colours"),
|
||||
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"),
|
||||
|
||||
@@ -144,6 +144,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
| Edit::AddAttachment { .. }
|
||||
| Edit::DeleteAttachment { .. }
|
||||
| Edit::ConvertColours { .. }
|
||||
| Edit::EditAttachmentDescription { .. }
|
||||
| Edit::AutoTagText
|
||||
| Edit::SetAltText { .. }
|
||||
| Edit::AutoTagFormFields
|
||||
@@ -638,6 +639,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("nothing on those pages needed converting".into())));
|
||||
}
|
||||
}
|
||||
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 strikeout is grouped under the caret, and XFDF names that link by
|
||||
// the caret's `/NM` (#2666).
|
||||
|
||||
@@ -49,7 +49,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
Edit::SetInfo { .. } => Scope::Metadata,
|
||||
Edit::AddPrinterMarks { .. } => Scope::Full,
|
||||
// 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(_)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,8 +31,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,
|
||||
|
||||
@@ -23,35 +23,47 @@ 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)
|
||||
}
|
||||
|
||||
/// Write a flat, sorted `/EmbeddedFiles` name tree, optionally renaming duplicate keys.
|
||||
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 reserved: std::collections::HashSet<String> = entries.iter().map(|(k, _)| pdfcraft_cos::PdfString::literal(k.clone()).to_text()).collect();
|
||||
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;
|
||||
if !seen.insert(text.clone()) {
|
||||
loop {
|
||||
text = format!("{base} ({n})");
|
||||
n += 1;
|
||||
if !reserved.contains(&text) && seen.insert(text.clone()) {
|
||||
break;
|
||||
if uniquify {
|
||||
let reserved: std::collections::HashSet<String> = entries.iter().map(|(k, _)| pdfcraft_cos::PdfString::literal(k.clone()).to_text()).collect();
|
||||
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;
|
||||
if !seen.insert(text.clone()) {
|
||||
loop {
|
||||
text = format!("{base} ({n})");
|
||||
n += 1;
|
||||
if !reserved.contains(&text) && seen.insert(text.clone()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if text != base {
|
||||
*k = pdfcraft_cos::PdfString::text(&text).bytes;
|
||||
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));
|
||||
|
||||
@@ -27,7 +27,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;
|
||||
|
||||
@@ -4177,6 +4177,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 {
|
||||
|
||||
@@ -656,6 +656,8 @@ pub struct Layer {
|
||||
pub struct Attachment {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Whether the file specification contained `/Desc`, including an empty value.
|
||||
pub has_description: bool,
|
||||
pub size: Option<usize>,
|
||||
pub source: AttachmentSource,
|
||||
}
|
||||
|
||||
@@ -227,7 +227,8 @@ impl<'a> Inspector<'a> {
|
||||
.and_then(|d| self.budgeted_text(d, b"UF", text_budget).or_else(|| self.budgeted_text(d, b"F", text_budget)))
|
||||
.or_else(|| fallback(text_budget))
|
||||
.unwrap_or_else(|| "attachment".into());
|
||||
Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), size, source }
|
||||
let has_description = d.is_some_and(|d| d.get(b"Desc").is_ok());
|
||||
Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), has_description, size, source }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,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(),
|
||||
|
||||
@@ -65,6 +65,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,
|
||||
@@ -364,6 +365,27 @@ 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, generation, _, _, _, _)) = app.attachment_description_draft.as_ref() else {
|
||||
req.close = true;
|
||||
return false;
|
||||
};
|
||||
if app.active_ids().is_none_or(|(_, active)| active != *doc)
|
||||
|| app.session.get(*doc).is_none_or(|current| current.edit_generation() != *generation)
|
||||
{
|
||||
app.attachment_description_draft = None;
|
||||
req.close = true;
|
||||
return false;
|
||||
}
|
||||
let Some((_, _, _, display_name, description, _)) = app.attachment_description_draft.as_mut() else {
|
||||
req.close = true;
|
||||
return false;
|
||||
};
|
||||
ui.heading(tl!("Edit attachment description"));
|
||||
ui.label(display_name.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;
|
||||
@@ -1756,6 +1778,7 @@ fn dialog_pin_tool(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending)
|
||||
true
|
||||
}
|
||||
|
||||
/// Draw the active dialog's footer and record any requested action in `req`.
|
||||
fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) {
|
||||
ui.add_space(16.0);
|
||||
let changed = draft_changes(app).is_some_and(|c| !c.is_empty());
|
||||
@@ -1792,6 +1815,30 @@ 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, generation, key, _, description, _)) = app.attachment_description_draft.as_ref() {
|
||||
let current = app.session.get(*doc).and_then(|doc| {
|
||||
doc.info
|
||||
.attachments
|
||||
.iter()
|
||||
.find(|attachment| matches!(&attachment.source, pdfcraft_render::AttachmentSource::Document { key: attachment_key } if attachment_key == key))
|
||||
.map(|attachment| (attachment.description.as_deref().unwrap_or_default().trim().to_owned(), attachment.has_description))
|
||||
});
|
||||
let normalized = description.trim();
|
||||
let still_current = app.session.get(*doc).is_some_and(|doc| doc.edit_generation() == *generation);
|
||||
if still_current && current.as_ref().is_some_and(|(current, has_description)| current != normalized || (normalized.is_empty() && *has_description)) {
|
||||
req.attachment_description_now = Some((
|
||||
*doc,
|
||||
Edit::EditAttachmentDescription { key: key.clone(), description: Some(normalized.to_owned()) },
|
||||
));
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -1875,6 +1922,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();
|
||||
}
|
||||
@@ -2139,6 +2194,9 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req:
|
||||
if app.dialog == Some(dialog) {
|
||||
app.dialog = None;
|
||||
}
|
||||
if dialog == Dialog::AttachmentDescription {
|
||||
app.attachment_description_draft = None;
|
||||
}
|
||||
app.props_draft = None;
|
||||
app.view_draft = None;
|
||||
app.custom_draft = None;
|
||||
|
||||
@@ -553,6 +553,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, u64, String, String, String, bool)>,
|
||||
/// Document Properties ▸ Initial View (and reading options) being edited.
|
||||
pub view_draft: Option<(DocId, pdfcraft_engine::InitialView)>,
|
||||
/// Document Properties ▸ Custom being edited.
|
||||
|
||||
@@ -367,6 +367,20 @@ 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,
|
||||
doc.edit_generation(),
|
||||
key.clone(),
|
||||
a.name.clone(),
|
||||
a.description.clone().unwrap_or_default(),
|
||||
a.has_description,
|
||||
));
|
||||
app.dialog = Some(crate::Dialog::AttachmentDescription);
|
||||
ui.close();
|
||||
}
|
||||
if let Some(key) = deletable
|
||||
&& ui.button(tl!("Delete attachment")).clicked()
|
||||
{
|
||||
|
||||
@@ -131,6 +131,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.
|
||||
|
||||
@@ -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();
|
||||
@@ -44,10 +44,14 @@ fn attachments_pdf() -> Vec<u8> {
|
||||
/// The app with [`attachments_pdf`] open and the Attachments panel showing, at 60 frames a
|
||||
/// second so two clicks a frame apart make a double-click.
|
||||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|_cc| {
|
||||
harness_with_pdf(attachments_pdf())
|
||||
}
|
||||
|
||||
fn harness_with_pdf(pdf: Vec<u8>) -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("doc.pdf", None, attachments_pdf()).unwrap();
|
||||
app.open_bytes("doc.pdf", None, pdf).unwrap();
|
||||
app.set_option("panel", "attachments").unwrap();
|
||||
app
|
||||
});
|
||||
@@ -55,6 +59,40 @@ fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submitting_blank_removes_an_existing_whitespace_description() {
|
||||
let mut pdf = attachments_pdf();
|
||||
let desc = pdf.windows(b"(Original)".len()).position(|window| window == b"(Original)").unwrap();
|
||||
pdf[desc..desc + b"(Original)".len()].copy_from_slice(b"( )");
|
||||
let mut h = harness_with_pdf(pdf);
|
||||
let doc_id = h.state().views[0].id;
|
||||
assert!(h.state().session.get(doc_id).unwrap().info.attachments.iter().find(|a| a.name == "notes.txt").unwrap().has_description);
|
||||
right_click(&mut h, "notes.txt");
|
||||
h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Edit description").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let doc = h.state().session.get(doc_id).unwrap();
|
||||
let attachment = doc.info.attachments.iter().find(|a| a.name == "notes.txt").unwrap();
|
||||
assert!(!attachment.has_description);
|
||||
assert!(doc.dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submitting_blank_leaves_an_absent_description_untouched() {
|
||||
let mut h = harness();
|
||||
right_click(&mut h, "inner.pdf");
|
||||
h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Edit description").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let doc = h.state().session.get(h.state().views[0].id).unwrap();
|
||||
let attachment = doc.info.attachments.iter().find(|a| a.name == "inner.pdf").unwrap();
|
||||
assert!(!attachment.has_description);
|
||||
assert!(!doc.dirty);
|
||||
assert_eq!(doc.can_undo(), None);
|
||||
}
|
||||
|
||||
/// A folder of its own for one test's saved file, removed afterwards.
|
||||
struct Dir(std::path::PathBuf);
|
||||
|
||||
@@ -151,6 +189,58 @@ 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 keeping_the_attachment_description_does_not_create_an_edit() {
|
||||
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);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let state = h.state();
|
||||
let doc = state.session.get(state.views[0].id).unwrap();
|
||||
assert_eq!(doc.info.attachments.iter().find(|a| a.name == "notes.txt").unwrap().description.as_deref(), Some("Original"));
|
||||
assert!(!doc.dirty, "confirming the unchanged description does not dirty the document");
|
||||
assert_eq!(doc.can_undo(), None, "confirming the unchanged description does not add undo history");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_the_document_while_description_dialog_is_open_discards_its_stale_draft() {
|
||||
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);
|
||||
assert!(
|
||||
h.state_mut()
|
||||
.apply_edit(pdfcraft_engine::Edit::EditAttachmentDescription { key: "notes.txt".into(), description: Some("Changed elsewhere".into()) })
|
||||
);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let state = h.state();
|
||||
let doc = state.session.get(state.views[0].id).unwrap();
|
||||
assert_eq!(doc.info.attachments.iter().find(|a| a.name == "notes.txt").unwrap().description.as_deref(), Some("Changed elsewhere"));
|
||||
assert_eq!(doc.can_undo(), Some("Edit attachment description"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_row_buttons_still_take_their_own_clicks() {
|
||||
let dir = Dir::new("buttons");
|
||||
|
||||
@@ -3487,11 +3487,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"
|
||||
|
||||
Reference in New Issue
Block a user