diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 119aaf4ac..be2df7735 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -329,6 +329,7 @@ fn info(args: &[String]) -> Result<(), CliError> { "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), "transitions": info.transitions.len(), + "custom_properties": info.custom.len(), "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), "warnings": info.warnings, }); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 5341ecfef..c01187306 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2406,6 +2406,9 @@ fn info(d: &Document) -> Value { "pdf_version": i.pdf_version, "file_size": i.file_size, "title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords, + // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the + // ones the spec names, set and cleared with doc_set_info. + "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), "creator": i.creator, "producer": i.producer, "tagged": i.tagged, "has_javascript": i.has_javascript, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 6b7b8a306..69d1eda21 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -156,7 +156,7 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords; an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.") + t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.") .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).") .ro() diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e2b7c811..6a348e0d8 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -238,6 +238,74 @@ fn saving_a_repaired_file_puts_the_repair_on_disk() { assert_eq!(info["pages"].as_array().unwrap().len(), 2); } +/// Custom document properties through the tools: set and cleared with doc_set_info under any +/// name the spec does not use, listed by doc_info apart from the standard entries, bad names +/// refused without a change. +#[test] +fn custom_document_properties_are_set_listed_and_cleared_through_the_tools() { + let dir = workdir("custom-info"); + std::fs::write(dir.join("a.pdf"), fixture(1)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Report" })); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Project", "value": "Apollo" })); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Revision", "value": "7" })); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["title"], "Report"); + let mut custom: Vec<(String, String)> = + info["custom"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap().into(), c["value"].as_str().unwrap().into())).collect(); + custom.sort(); + assert_eq!(custom, [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "7".to_string())], "{}", info["custom"]); + assert!(a.call("doc_set_info", &json!({ "doc": doc, "key": "Bad Name", "value": "x" })).is_err(), "a name with a space is refused"); + assert!(a.call("doc_set_info", &json!({ "doc": doc, "key": "", "value": "x" })).is_err()); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Project", "value": "" })); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["custom"].as_array().unwrap().len(), 1, "{}", info["custom"]); + assert_eq!(info["custom"][0]["name"], "Revision"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/custom.pdf" })); + let mut b = auto(&dir); + let re = ok(&mut b, "doc_open", json!({ "path": "out/custom.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut b, "doc_info", json!({ "doc": re })); + assert_eq!(info["custom"][0]["value"], "7", "{}", info["custom"]); +} + +/// The custom list `doc_info` shows when a file opens is the one it shows after an unrelated +/// edit: a name value, a blank or NUL-padded string and a number are judged the same way by the +/// open-time reader and by the engine's refresh. +#[test] +fn custom_document_properties_do_not_change_after_an_unrelated_edit() { + let dir = workdir("custom-info-stable"); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>", + "<< /Producer (Test) /Project (Apollo) /Dept /RnD /Blank ( ) /Padded (Lab\\000\\000) /Revision 7 /Empty () /Mixed ( \\000Lab\\000 ) /Pad ( \\000 ) >>", + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R /Info 4 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("custom.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "custom.pdf" }))["doc"].as_u64().unwrap(); + let opened = ok(&mut a, "doc_info", json!({ "doc": doc }))["custom"].clone(); + let names: Vec<&str> = opened.as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Project", "Padded", "Mixed"], "{opened}"); + assert_eq!(opened[1]["value"], "Lab", "{opened}"); + assert_eq!(opened[2]["value"], "Lab", "{opened}"); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Report" })); + let edited = ok(&mut a, "doc_info", json!({ "doc": doc }))["custom"].clone(); + assert_eq!(edited, opened); +} + #[test] fn autotag_preview_proposes_text_without_changing_the_pdf() { let dir = workdir("autotag-preview"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 64ca4a979..bf689af54 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -831,6 +831,11 @@ impl Document { self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) } + /// The custom document properties as they are now (name, text), Document Properties ▸ Custom. + pub fn custom_info(&self) -> Vec<(String, String)> { + self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default() + } + /// The name shown on the tab and window: the document title when the document asks for it /// (Initial View ▸ Show: Document Title) and has a real one, else the file name. pub fn display_name(&self) -> String { @@ -3905,6 +3910,7 @@ impl Session { doc.info.keywords = info("Keywords"); doc.info.creator = info("Creator"); doc.info.producer = info("Producer"); + doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); doc.info.file_size = bytes.len(); if !doc.signatures.is_empty() { let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index bdd038527..e120d486b 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -563,6 +563,54 @@ pub fn insert_blank_page(doc: &mut Document, at: usize, width: f64, height: f64) /// Document information keys editable in Document Properties ▸ Description. pub const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; +/// The document-information entries the spec names (ISO 32000-2 table 349). Any other key in +/// `/Info` is a custom property (Document Properties ▸ Custom). +pub const STANDARD_INFO_KEYS: [&str; 9] = ["Title", "Author", "Subject", "Keywords", "Creator", "Producer", "CreationDate", "ModDate", "Trapped"]; + +/// Longest custom property name accepted, in bytes: the name length every reader is asked to +/// accept (§7.3.5, Annex C). +pub const MAX_CUSTOM_KEY_LEN: usize = 127; + +/// The custom document properties: every `/Info` entry whose key the spec does not name and whose +/// value is text, in the dictionary's order, as (name, text), the text without NUL or whitespace +/// padding and entries that are only padding left out. At most 256 are listed. +pub fn custom_info(doc: &Document) -> Vec<(String, String)> { + let Some(info) = doc.trailer().get(b"Info").map(|o| doc.resolve(o)) else { return Vec::new() }; + let Some(d) = info.as_dict() else { return Vec::new() }; + d.iter() + .filter(|(k, _)| !STANDARD_INFO_KEYS.iter().any(|s| s.as_bytes() == k.as_slice())) + .filter_map(|(k, v)| { + // Padding (\0s and whitespace, in any mix) is not part of the value, and a value that + // is only padding is no property. Filtered before the cap, so blank entries can't + // crowd real ones out. + let text = doc.resolve(v).as_string()?.to_text(); + let text = text.trim_matches(|c: char| c == '\0' || c.is_whitespace()); + (!text.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), text.to_owned())) + }) + .take(256) + .collect() +} + +/// Whether `name` may be a custom property's key: not one the spec names (those are the +/// Description fields), not empty, at most [`MAX_CUSTOM_KEY_LEN`] bytes, and without the +/// characters a name cannot carry unescaped (whitespace, the delimiters `()<>[]{}/%` and `#`), +/// so every reader shows the same name. Acrobat refuses the standard and empty names too. +pub fn check_custom_key(name: &str) -> Result<(), OrganizeError> { + if name.is_empty() { + return Err(OrganizeError::Invalid("a custom property needs a name".into())); + } + if STANDARD_INFO_KEYS.contains(&name) { + return Err(OrganizeError::Invalid(format!("{name} is a standard document property, not a custom one"))); + } + if name.len() > MAX_CUSTOM_KEY_LEN { + return Err(OrganizeError::Invalid(format!("a custom property name is at most {MAX_CUSTOM_KEY_LEN} bytes"))); + } + if let Some(c) = name.chars().find(|c| c.is_whitespace() || c.is_control() || "()<>[]{}/%#".contains(*c)) { + return Err(OrganizeError::Invalid(format!("a custom property name can't contain {c:?}"))); + } + Ok(()) +} + /// Read a document-information entry as text. pub fn info(doc: &Document, key: &str) -> Option { let info = doc.trailer().get(b"Info").map(|o| doc.resolve(o))?; @@ -575,6 +623,9 @@ pub fn info(doc: &Document, key: &str) -> Option { /// in the XMP packet, and viewers that read XMP first show its value: the packet's property is /// changed (or removed) to match (see `xmp`). pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { + if !STANDARD_INFO_KEYS.contains(&key) { + check_custom_key(key)?; + } let value = value.trim(); let entry = (!value.is_empty()).then(|| Object::String(PdfString::text(value))); set_info_entry(doc, key.as_bytes(), entry)?; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 1b1732082..32a386d22 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -383,6 +383,59 @@ fn page_transitions_are_set_removed_and_checked() { assert_eq!(page_dict(&doc, 2).get(b"Dur").and_then(Object::as_f64), Some(5.0)); } +/// Custom document properties (Document Properties ▸ Custom): any `/Info` key the spec does not +/// name, set and cleared like the standard ones, listed apart from them, and with the names +/// Acrobat refuses refused here too. +#[test] +fn custom_document_properties_are_listed_set_removed_and_their_names_checked() { + let mut doc = open(fixture()); + assert!(custom_info(&doc).is_empty()); + set_info(&mut doc, "Title", "Report").unwrap(); + set_info(&mut doc, "Project", "Apollo").unwrap(); + set_info(&mut doc, "Revision", "7").unwrap(); + let mut listed = custom_info(&doc); + listed.sort(); + assert_eq!(listed, [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "7".to_string())], "standard keys are not custom"); + assert_eq!(info(&doc, "Project").as_deref(), Some("Apollo")); + set_info(&mut doc, "Project", "").unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(custom_info(&doc), [("Revision".to_string(), "7".to_string())], "cleared, and the rest survives a save"); + assert_eq!(info(&doc, "Title").as_deref(), Some("Report")); + + for bad in ["", "Title", "ModDate", "Bad Name", "a/b", "tab\tbed", "hash#tag", "(x)"] { + assert!(check_custom_key(bad).is_err(), "{bad:?} must be refused"); + } + let long = "x".repeat(MAX_CUSTOM_KEY_LEN + 1); + assert!(check_custom_key(&long).is_err()); + for good in ["Project", "Révision", "部門", "key-2.0", &"y".repeat(MAX_CUSTOM_KEY_LEN)] { + assert!(check_custom_key(good).is_ok(), "{good:?} is a usable name"); + } + let mut doc = open(fixture()); + assert!(set_info(&mut doc, "Bad Name", "x").is_err(), "set_info checks custom names"); + assert!(custom_info(&doc).is_empty()); +} + +/// Padding is not part of a custom value, in any mix of NULs and whitespace, a value that is only +/// padding is not listed, and such entries don't count towards the 256 listed: a real property +/// after 300 blank ones is still there. +#[test] +fn custom_document_properties_drop_padding_before_the_cap() { + let mut doc = open(fixture()); + set_info(&mut doc, "Title", "Report").unwrap(); + let Some(Object::Ref(info_ref)) = doc.trailer().get(b"Info").cloned() else { panic!("an /Info reference") }; + let text = |s: &str| Object::String(pdfcraft_cos::PdfString { bytes: s.as_bytes().to_vec(), hex: false }); + doc.update_dict(info_ref, |d| { + d.set(b"Mixed".to_vec(), text(" \0Lab\0 ")); + d.set(b"Pad".to_vec(), text(" \0 \0")); + for i in 0..300 { + d.set(format!("Blank{i}").into_bytes(), text(" ")); + } + d.set(b"Keep".to_vec(), text("yes")); + }) + .unwrap(); + assert_eq!(custom_info(&doc), [("Mixed".to_string(), "Lab".to_string()), ("Keep".to_string(), "yes".to_string())]); +} + /// A one-page document whose catalog has `xmp` as its (Flate-compressed) XMP packet, and the /// same title and author in `/Info`, as LibreOffice writes them. fn doc_with_xmp(xmp: &[u8]) -> Document { diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 1b6e8dd4d..0979f3d38 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -56,6 +56,9 @@ pub struct DocInfo { pub keywords: Option, pub creator: Option, pub producer: Option, + /// Custom document properties: `/Info` entries with a key the spec does not name and a text + /// value, as (name, text), in the dictionary's order (at most 256). + pub custom: Vec<(String, String)>, pub pdf_version: String, pub file_size: usize, pub encrypted: bool, @@ -616,6 +619,23 @@ impl<'a> Inspector<'a> { info.keywords = self.text(d, b"Keywords"); info.creator = self.text(d, b"Creator"); info.producer = self.text(d, b"Producer"); + const STANDARD: [&[u8]; 9] = + [b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"]; + // The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`, + // trimmed and without empty values): text strings only, so a name value is not a + // custom property, and the cap counts listed entries. + info.custom = d + .iter() + .filter(|(k, _)| !STANDARD.contains(&k.as_slice())) + .filter_map(|(k, v)| match self.resolve(v) { + Object::String(bytes, _) => { + let v = text_string(bytes).trim_matches(|c: char| c == '\0' || c.is_whitespace()).to_string(); + (!v.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), v)) + } + _ => None, + }) + .take(256) + .collect(); } let Ok(catalog) = self.doc.catalog() else { return }; info.tagged = catalog @@ -2308,6 +2328,27 @@ trailer << /Root 1 0 R >> ); } + const CUSTOM_INFO: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Title (Report) /Producer (Test) /Project (Apollo) /Revision 7 /Dept (R&D) /Trapped /False /Note >> endobj +trailer << /Root 1 0 R /Info 4 0 R >> +%%EOF"; + + #[test] + fn custom_document_properties_are_listed_apart_from_the_standard_ones() { + let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens"); + assert_eq!((info.title.as_deref(), info.producer.as_deref()), (Some("Report"), Some("Test"))); + let mut custom = info.custom.clone(); + custom.sort(); + // Revision is a number, not text, so it is not listed; Trapped is a standard entry. + assert_eq!( + custom, + [("Dept".to_string(), "R&D".to_string()), ("Note".to_string(), "部".to_string()), ("Project".to_string(), "Apollo".to_string())] + ); + } + #[test] fn radio_button_layer_groups_are_read() { let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a09ef8e9a..d8fbe77dd 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3481,11 +3481,11 @@ title = "Custom document properties" area = "D" tier = "P2" milestone = "M4" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_info", "doc_set_info"] +evidence = ["crates/organize/src/tests.rs::custom_document_properties_are_listed_set_removed_and_their_names_checked", "crates/render/src/inspect.rs::custom_document_properties_are_listed_apart_from_the_standard_ones", "crates/automation/tests/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools"] +notes = "Through the tools: doc_set_info sets or clears an /Info entry under any name the spec does not use (names checked as Acrobat does: no standard name, no empty name, no spaces or delimiters, at most 127 bytes), and doc_info lists them under custom, apart from the standard entries. Not yet: the Custom tab in Document Properties, and mirroring them into XMP (pdfx:) as Acrobat does" [[feature]] id = "organize.xmp-editor"