From a12fe8f4116984ebbc870eb514346ea922233316 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:49:30 +0800 Subject: [PATCH] M4: custom document properties through the engine and the tools (#2076) * M4: custom document properties through the engine and the tools (#organize.custom-properties) /Info entries beyond the ones the spec names are custom properties: organize lists them (custom_info) and set_info accepts any such name after Acrobat's checks (no standard name, no empty name, no spaces, delimiters, / or #, at most 127 bytes); inspection lists them too (DocInfo::custom); doc_info reports them under custom and doc_set_info sets or clears them. The Custom tab in Document Properties and the XMP mirror stay planned. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz * M4: custom document properties list the same before and after an edit Review follow-up. The open-time reader (render/inspect.rs) now uses the engine's rule (pdfcraft_organize::custom_info, trimmed, empty values dropped): text strings only, NUL/whitespace trimmed, empty values left out, and the 256 cap counting listed entries. Before, it also listed name values (`/Dept /RnD`) and capped before filtering, so the list could change after the first unrelated edit. An automation test opens a file with a name value, blank, NUL-padded, empty and numeric entries and checks doc_info's custom list is the same before and after setting the Title. Drops the ROADMAP.md log line, which conflicted with main. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz * M4: custom properties drop NUL/whitespace padding in one pass, before the cap Review follow-up. custom_info now trims NULs and whitespace in any mix (" \0Lab\0 " is "Lab"), leaves out values that are only padding, and does both before taking 256, so blank entries can't crowd real ones out. It is the one rule: the engine uses it as it is, and the open-time reader trims the same way. Tests: 300 blank entries before a real one, and mixed padding, in organize and through doc_info. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Brandon Thomas --- apps/pdfcraft-cli/src/main.rs | 1 + crates/automation/src/lib.rs | 3 ++ crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 68 +++++++++++++++++++++++++++ crates/engine/src/lib.rs | 6 +++ crates/organize/src/lib.rs | 51 ++++++++++++++++++++ crates/organize/src/tests.rs | 53 +++++++++++++++++++++ crates/render/src/inspect.rs | 41 ++++++++++++++++ parity/acrobat-features.toml | 8 ++-- 9 files changed, 228 insertions(+), 5 deletions(-) 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 8a4dcec16..a27b44713 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2401,6 +2401,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 683b62686..d541e5ba8 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 07ba5503c..dbeb52e7c 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 { @@ -3902,6 +3907,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 133bc03ec..25decd767 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -556,6 +556,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))?; @@ -568,6 +616,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 c857cd4da..b5f85386c 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -336,6 +336,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 3c3988b7b..12db8abc3 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 @@ -2302,6 +2322,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 deb0d9471..de2ca11f4 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"