From 6996aac44ac093e8aab0f8e5e38043b7ae8fcdb0 Mon Sep 17 00:00:00 2001 From: magdale76 <16279778+magdale76@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:15:37 +0200 Subject: [PATCH] M3: Document Properties shows a title and author stated only in XMP (#2373) Description read Title, Author, Subject and Keywords only from /Info. A file that states them only in its XMP packet (/Info is deprecated in PDF 2.0, and some writers leave it out) showed empty fields, while Acrobat shows them. organize::info now falls back to the packet's dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool and pdf:Producer: a language alternative gives its x-default item, and several authors are joined with "; ". The engine fills the inspection's missing fields the same way, so the read-only Description rows and doc_info agree with the editable fields. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 37 +++++++--- crates/engine/src/tests.rs | 24 +++++++ crates/organize/src/lib.rs | 12 +++- crates/organize/src/tests.rs | 37 ++++++++++ crates/organize/src/xmp.rs | 130 +++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 6 files changed, 229 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f257865d3a..e3e077ceed 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -946,6 +946,20 @@ fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) && a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation) } +/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read +/// the way the full inspection reads them: NULs and spaces trimmed, empty is none. +fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 6] { + let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); + [ + (&mut info.title, read("Title")), + (&mut info.author, read("Author")), + (&mut info.subject, read("Subject")), + (&mut info.keywords, read("Keywords")), + (&mut info.creator, read("Creator")), + (&mut info.producer, read("Producer")), + ] +} + /// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for /// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page /// images agree with the app and independent viewers. It never changes the source document, and an @@ -3666,7 +3680,7 @@ impl Session { name: String, path: Option, bytes: Arc>, - info: DocInfo, + mut info: DocInfo, cos: Result, Box>, render_password: Option, password: Option<&str>, @@ -3698,6 +3712,15 @@ impl Session { }; let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); + // The inspection reads only `/Info`; a file stating its title or author only in XMP still + // shows them (#2373). + if let Some(e) = &editor { + for (field, value) in info_fields(&mut info, &e.cos) { + if field.is_none() { + *field = value; + } + } + } let display = display_bytes(editor.as_mut(), &bytes); let renderer = RenderPool::new(display.clone(), render_threads(), config.clone()); let (form, adopted) = editor @@ -4063,15 +4086,9 @@ impl Session { editor.cos.bytes().clone() }; if scope == Scope::Metadata { - // Read the way the full inspection reads them (NULs and spaces trimmed, empty is - // none), so editing one entry doesn't change how the others display. - let info = |key| pdfcraft_organize::info(&editor.cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); - doc.info.title = info("Title"); - doc.info.author = info("Author"); - doc.info.subject = info("Subject"); - doc.info.keywords = info("Keywords"); - doc.info.creator = info("Creator"); - doc.info.producer = info("Producer"); + for (field, value) in info_fields(&mut doc.info, &editor.cos) { + *field = value; + } doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); doc.info.file_size = bytes.len(); #[cfg(test)] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3012a1f3fe..1e2485c661 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -425,6 +425,30 @@ impl RenderRequestFor { } } +/// A file with its title and author only in XMP shows them in the document information and in +/// Document Properties ▸ Description (#2373). +#[test] +fn document_information_stated_only_in_xmp_is_shown() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + let xmp = "\ + \ + Quarterly report\ + Ada"; + let mut dict = pdfcraft_cos::Dict::new(); + dict.set(b"Type".to_vec(), pdfcraft_cos::Object::name("Metadata")); + dict.set(b"Subtype".to_vec(), pdfcraft_cos::Object::name("XML")); + let m = cos.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(dict, xmp.as_bytes().to_vec()))); + let root = cos.root().unwrap(); + cos.update_dict(root, |c| c.set(b"Metadata".to_vec(), pdfcraft_cos::Object::Ref(m))).unwrap(); + let bytes = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let mut s = Session::new(); + let id = s.open("xmp.pdf", None, Arc::new(bytes), None).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!((doc.info.title.as_deref(), doc.info.author.as_deref(), doc.info.subject.as_deref()), (Some("Quarterly report"), Some("Ada"), None)); + assert_eq!(doc.info_value("Title").as_deref(), Some("Quarterly report")); + assert_eq!(doc.info_value("Author").as_deref(), Some("Ada")); +} + #[test] fn edits_update_view_data_and_mark_dirty() { let (mut s, id) = session_with(3); diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 6a696dd4db..9d37de0f60 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -658,10 +658,16 @@ pub fn check_custom_key(name: &str) -> Result<(), OrganizeError> { Ok(()) } -/// Read a document-information entry as text. +/// Read a document-information entry as text: `/Info`, else the XMP packet (Title, Author, +/// Subject, Keywords, Creator and Producer, see `xmp`). pub fn info(doc: &Document, key: &str) -> Option { - let info = doc.trailer().get(b"Info").map(|o| doc.resolve(o))?; - info.as_dict()?.get(key.as_bytes()).and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text())) + let in_info = doc + .trailer() + .get(b"Info") + .map(|o| doc.resolve(o)) + .and_then(|info| info.as_dict()?.get(key.as_bytes()).and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text()))); + // Files that state it only in their XMP packet (#2373). + in_info.filter(|v| !v.trim_matches('\0').trim().is_empty()).or_else(|| xmp::info_value(doc, key)) } /// Set (or clear, with an empty value) a document-information entry. diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 7a80e38e4c..89eeb43573 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -628,6 +628,43 @@ fn document_info_edits_update_the_xmp_packet() { assert_eq!((dict.name(b"Type"), dict.name(b"Subtype"), dict.get(b"Filter")), (Some(&b"Metadata"[..]), Some(&b"XML"[..]), None)); } +/// A file that states its title, author and so on only in XMP (PDF 2.0 deprecates `/Info`) shows +/// them in Document Properties (#2373); `/Info` wins where it has a value. +#[test] +fn document_info_falls_back_to_the_xmp_packet() { + let xmp = LIBREOFFICE_XMP + .replace( + "Untitled 1", + "BerichtReport & “notes”", + ) + .replace("Old Author", "Ada\nGrace") + .replace("Writer 1.0", "Writer 1.0"); + let mut doc = doc_with_xmp(xmp.as_bytes()); + // Both: /Info's own values, XMP for what /Info lacks. + assert_eq!(info(&doc, "Title").as_deref(), Some("Untitled 1")); + assert_eq!(info(&doc, "Subject").as_deref(), Some("Old subject")); + // XMP only. + doc.trailer_mut().remove(b"Info"); + let doc = full_roundtrip(&doc); + assert_eq!(info(&doc, "Title").as_deref(), Some("Report & \u{201C}notes\u{201D}")); + assert_eq!(info(&doc, "Author").as_deref(), Some("Ada; Grace")); + assert_eq!(info(&doc, "Subject").as_deref(), Some("Old subject")); + assert_eq!(info(&doc, "Producer").as_deref(), Some("Writer 1.0")); + assert_eq!(info(&doc, "Keywords"), None, "an empty property is no value"); + assert_eq!(info(&doc, "Rating"), None, "no XMP counterpart"); + // The attribute form, and a packet that isn't UTF-8 is not read. + let attrs = "\ + "; + let mut doc = doc_with_xmp(attrs.as_bytes()); + doc.trailer_mut().remove(b"Info"); + assert_eq!(info(&doc, "Keywords").as_deref(), Some("a, b & c")); + let mut latin1 = LIBREOFFICE_XMP.as_bytes().to_vec(); + latin1.extend_from_slice(b""); + let mut doc = doc_with_xmp(&latin1); + doc.trailer_mut().remove(b"Info"); + assert_eq!(info(&doc, "Subject"), None); +} + #[test] fn document_info_edits_leave_files_without_usable_xmp_alone() { // No packet: none is added. diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs index bbcbbcf83f..21cb282c21 100644 --- a/crates/organize/src/xmp.rs +++ b/crates/organize/src/xmp.rs @@ -92,6 +92,120 @@ fn edit_packet(doc: &mut Document, edit: impl FnOnce(&str) -> Option) -> Ok(true) } +/// The value the catalog's XMP packet gives the document information key `key`, for files that +/// state their title, author and so on only there (`/Info` is deprecated in PDF 2.0, and some +/// writers leave it out). A language alternative gives its `x-default` item (else the first), +/// several authors are joined with "; ". `None` for keys XMP doesn't mirror, files without XMP, +/// packets that can't be read safely, and empty values. +pub(crate) fn info_value(doc: &Document, key: &str) -> Option { + let (name, _, shape) = property(key)?; + let root = doc.root()?; + let r = doc.get(root).as_dict()?.reference(b"Metadata")?; + let obj = doc.get(r); + let Object::Stream(stream) = &*obj else { return None }; + let xmp = String::from_utf8(stream.decoded_within(MAX_XMP).ok()?).ok()?; + get_property(&xmp, name, shape) +} + +/// The text of the property `name` in `xmp`, element or attribute form (see [`info_value`]). +fn get_property(xmp: &str, name: &str, shape: Shape) -> Option { + let value = if let Some((start, end)) = element_span(xmp, name) { + let element = xmp.get(start..end)?; + let open_end = element.find('>')?; + // ``: no value. + if element.get(..open_end)?.ends_with('/') { + return None; + } + let inner = element.get(open_end + 1..)?.strip_suffix(&format!(""))?; + let items = list_items(inner); + if items.is_empty() { + unescape(inner.trim()) + } else if shape == Shape::LangAlt { + let (_, text) = items.iter().find(|(lang, _)| lang.as_deref() == Some("x-default")).or(items.first())?; + unescape(text.trim()) + } else { + items.iter().map(|(_, text)| unescape(text.trim())).filter(|t| !t.is_empty()).collect::>().join("; ") + } + } else { + let (_, value_start, value_end, _) = attribute_span(xmp, name)?; + unescape(xmp.get(value_start..value_end)?.trim()) + }; + Some(value).filter(|v| !v.trim().is_empty()) +} + +/// The `rdf:li` items of a property's content: each one's `xml:lang` (if any) and its text. +fn list_items(inner: &str) -> Vec<(Option, &str)> { + let mut items = Vec::new(); + let mut rest = inner; + while let Some(i) = rest.find("' || c == '/' || c.is_whitespace()) { + rest = after; + continue; + } + let Some(tag_end) = after.find('>') else { break }; + let tag = after.get(..tag_end).unwrap_or_default(); + let lang = tag.find("xml:lang=").and_then(|at| { + let v = tag.get(at + "xml:lang=".len()..)?; + let quote = v.chars().next().filter(|c| matches!(c, '"' | '\''))?; + let v = v.get(1..)?; + Some(v.get(..v.find(quote)?)?.to_string()) + }); + let Some(body) = after.get(tag_end + 1..) else { break }; + if tag.ends_with('/') { + items.push((lang, "")); + rest = body; + continue; + } + let Some(close) = body.find("") else { break }; + items.push((lang, body.get(..close).unwrap_or_default())); + rest = body.get(close..).unwrap_or_default(); + } + items +} + +/// XML text with its character and entity references resolved (an unknown one is kept as is). +fn unescape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(i) = rest.find('&') { + out.push_str(rest.get(..i).unwrap_or_default()); + let after = rest.get(i..).unwrap_or_default(); + let Some(semi) = after.find(';').filter(|&n| n <= 12) else { + out.push('&'); + rest = after.get(1..).unwrap_or_default(); + continue; + }; + let entity = after.get(1..semi).unwrap_or_default(); + let c = match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => entity + .strip_prefix("#x") + .or_else(|| entity.strip_prefix("#X")) + .and_then(|h| u32::from_str_radix(h, 16).ok()) + .or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok())) + .and_then(char::from_u32), + }; + match c { + Some(c) => { + out.push(c); + rest = after.get(semi + 1..).unwrap_or_default(); + } + None => { + out.push('&'); + rest = after.get(1..).unwrap_or_default(); + } + } + } + out.push_str(rest); + out +} + /// `xmp` with the property `name` (namespace `ns`) set to `value`, or removed when `value` is /// empty; `None` when nothing changes or the packet has no `rdf:RDF` to add the property to. fn set_property(xmp: &str, name: &str, ns: &str, shape: Shape, value: &str) -> Option { @@ -206,6 +320,22 @@ mod tests { Old subject\n\ \n \n\n"; + #[test] + fn values_are_read_back_unescaped() { + assert_eq!(unescape("a & b <c> AB &bogus; � & end"), "a & b AB &bogus; � & end"); + assert_eq!(get_property(PACKET, "dc:title", Shape::LangAlt).as_deref(), Some("Untitled 1")); + assert_eq!(get_property(PACKET, "dc:creator", Shape::Seq).as_deref(), Some("Old Author")); + assert_eq!(get_property(PACKET, "pdf:Producer", Shape::Text).as_deref(), Some("Writer 1.0")); + assert_eq!(get_property(PACKET, "pdf:Keywords", Shape::Text), None); + // No x-default: the first language; empty and self-closed items are skipped. + let alt = "RapportReport"; + assert_eq!(get_property(alt, "dc:title", Shape::LangAlt).as_deref(), Some("Rapport")); + let seq = " Ada"; + assert_eq!(get_property(seq, "dc:creator", Shape::Seq).as_deref(), Some("Ada")); + // Unfinished markup reads as nothing rather than as a wrong value. + assert_eq!(get_property("Cut", "dc:title", Shape::LangAlt), None); + } + #[test] fn element_values_are_replaced_in_place() { let out = set_property(PACKET, "dc:title", DC, Shape::LangAlt, "Guide & ").unwrap(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 45dda39822..34386b5129 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3448,8 +3448,8 @@ milestone = "M3" status = "shipped" commands = ["file.properties"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::cancelling_properties_discards_the_draft"] -notes = "" +evidence = ["crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::cancelling_properties_discards_the_draft", "crates/organize/src/tests.rs::document_info_falls_back_to_the_xmp_packet", "crates/engine/src/tests.rs::document_information_stated_only_in_xmp_is_shown"] +notes = "Title, Author, Subject and Keywords (and Creator, Producer in doc_info) come from /Info, else from the XMP packet (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) for files that state them only there; edits write both. Missing: a UTF-16 XMP packet isn't read, and /Info kept only in an older revision's trailer isn't looked up" [[feature]] id = "organize.edit-metadata"