From 9324afaa4fdf75195eb345cd2eed2ac7bb44a4d7 Mon Sep 17 00:00:00 2001 From: Alex <270190108+CouchRiv@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:18:35 -0400 Subject: [PATCH] fix(organize): Document Properties also change the XMP title and author (#1658) `set_info` changed only `/Info`. Files that carry XMP (LibreOffice and Word exports, PDF/A) kept the old dc:title and dc:creator, so viewers that read XMP first kept showing the old title, and PDF/A preflight reported the two as different (6.6.3). The matching XMP property (Title dc:title, Author dc:creator, Subject dc:description, Keywords pdf:Keywords, Creator xmp:CreatorTool, Producer pdf:Producer) is now replaced where it stands, added in a description of its own, or removed with an empty value. The packet is edited as text and the rest of it stays byte for byte; files without XMP get none, and a packet that isn't UTF-8 is left as it was. Co-authored-by: Claude Opus 5.5 --- crates/automation/src/tools.rs | 2 +- crates/organize/src/lib.rs | 9 +- crates/organize/src/pdfx.rs | 2 +- crates/organize/src/tests.rs | 95 +++++++++++++ crates/organize/src/xmp.rs | 251 +++++++++++++++++++++++++++++++++ docs/pdf-spec-parity.md | 2 +- parity/acrobat-features.toml | 4 +- 7 files changed, 357 insertions(+), 8 deletions(-) create mode 100644 crates/organize/src/xmp.rs diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index ad407f8a..ee58063a 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. Undoable.") + 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.") .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/organize/src/lib.rs b/crates/organize/src/lib.rs index ffbf7cb9..7b366bad 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -21,6 +21,7 @@ mod outline; mod pdfx; mod prune; pub mod view; +mod xmp; pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; pub use dedupe::dedupe_resources; @@ -504,12 +505,14 @@ pub fn info(doc: &Document, key: &str) -> Option { /// Set (or clear, with an empty value) a document-information entry. /// -/// Note: documents with XMP metadata also carry these values in the XMP packet; synchronising -/// XMP arrives with the `model` crate (M2) — viewers that prefer XMP may still show old values. +/// Documents with XMP metadata also carry Title, Author, Subject, Keywords, Creator and Producer +/// 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> { let value = value.trim(); let entry = (!value.is_empty()).then(|| Object::String(PdfString::text(value))); - set_info_entry(doc, key.as_bytes(), entry) + set_info_entry(doc, key.as_bytes(), entry)?; + xmp::sync_info(doc, key, value) } /// Set (or remove, with `None`) a document-information entry of any type. diff --git a/crates/organize/src/pdfx.rs b/crates/organize/src/pdfx.rs index 3241d430..1aa4fc50 100644 --- a/crates/organize/src/pdfx.rs +++ b/crates/organize/src/pdfx.rs @@ -191,7 +191,7 @@ fn unescape(s: &str) -> String { } /// `s` as XML text: markup escaped, and characters XML 1.0 does not allow left out. -fn esc(s: &str) -> String { +pub(crate) fn esc(s: &str) -> String { s.chars() .filter(|c| (!c.is_control() || matches!(c, '\t' | '\n' | '\r')) && !matches!(c, '\u{FFFE}' | '\u{FFFF}')) .collect::() diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index ec9f610f..8b0fcfee 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -267,6 +267,101 @@ fn document_info_edits_round_trip_with_unicode() { assert_eq!(info(&doc, "Producer"), None); } +/// 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 { + let b = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".into(), + body("X"), + "<< /Title (Untitled 1) /Author (Old Author) >>".into(), + ], + "/Root 1 0 R /Info 5 0 R", + ); + let mut doc = open(b); + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("Metadata")); + dict.set(b"Subtype".to_vec(), Object::name("XML")); + let m = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(dict, xmp))); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Metadata".to_vec(), Object::Ref(m))).unwrap(); + full_roundtrip(&doc) +} + +/// The catalog's metadata stream. +fn xmp_stream(doc: &Document) -> Option { + match &*doc.get(catalog(doc).reference(b"Metadata")?) { + Object::Stream(s) => Some(s.clone()), + _ => None, + } +} + +fn xmp_text(doc: &Document) -> Option { + xmp_stream(doc).map(|s| String::from_utf8(s.decoded().unwrap()).unwrap()) +} + +/// The values the XMP packet gives a property: the items of its rdf:Alt or rdf:Seq, else its text. +fn xmp_values(xmp: &str, name: &str) -> Vec { + let Some(start) = xmp.find(&format!("<{name}>")) else { return Vec::new() }; + let end = xmp[start..].find(&format!("")).unwrap() + start; + let inner = &xmp[start + name.len() + 2..end]; + if !inner.contains("').unwrap() + 1..li.find("").unwrap()].to_string()).collect() +} + +const LIBREOFFICE_XMP: &str = "\n\ +\n \n\ + Writer 1.0\n\ + \n\ + Untitled 1\n\ + Old Author\n\ + Old subject\n\ + \n \n\n"; + +#[test] +fn document_info_edits_update_the_xmp_packet() { + // Viewers that read XMP first (pdf.js takes dc:title) showed the old title after a change. + let mut doc = doc_with_xmp(LIBREOFFICE_XMP.as_bytes()); + assert_eq!(xmp_values(&xmp_text(&doc).unwrap(), "dc:title"), ["Untitled 1"]); + set_info(&mut doc, "Title", "Sales Guide v1.0 & ").unwrap(); + set_info(&mut doc, "Author", "Demo Consulting").unwrap(); + set_info(&mut doc, "Subject", "").unwrap(); + set_info(&mut doc, "Keywords", "alpha, beta").unwrap(); + set_info(&mut doc, "Rating", "5").unwrap(); // no XMP counterpart: /Info only + let doc = save_and_reopen(&doc); + assert_eq!(info(&doc, "Title").as_deref(), Some("Sales Guide v1.0 & ")); + let xmp = xmp_text(&doc).unwrap(); + assert_eq!(xmp_values(&xmp, "dc:title"), ["Sales Guide v1.0 & <draft>"]); + assert_eq!(xmp_values(&xmp, "dc:creator"), ["Demo Consulting"]); + assert!(xmp_values(&xmp, "dc:description").is_empty(), "{xmp}"); + assert_eq!(xmp_values(&xmp, "pdf:Keywords"), ["alpha, beta"]); + assert_eq!(xmp_values(&xmp, "pdf:Producer"), ["Writer 1.0"]); + assert!(xmp.starts_with(""), "{xmp}"); + // Stored unfiltered, still typed as metadata. + let dict = xmp_stream(&doc).unwrap().dict; + assert_eq!((dict.name(b"Type"), dict.name(b"Subtype"), dict.get(b"Filter")), (Some(&b"Metadata"[..]), Some(&b"XML"[..]), None)); +} + +#[test] +fn document_info_edits_leave_files_without_usable_xmp_alone() { + // No packet: none is added. + let mut doc = open(fixture()); + set_info(&mut doc, "Title", "New").unwrap(); + assert_eq!(xmp_text(&save_and_reopen(&doc)), None); + // A packet that isn't UTF-8 is not rewritten. + let mut latin1 = LIBREOFFICE_XMP.as_bytes().to_vec(); + latin1.extend_from_slice(b""); + let mut doc = doc_with_xmp(&latin1); + set_info(&mut doc, "Title", "New").unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(info(&doc, "Title").as_deref(), Some("New")); + assert_eq!(xmp_stream(&doc).unwrap().decoded().unwrap(), latin1); +} + #[test] fn successive_incremental_saves_stack_revisions() { let mut doc = open(fixture()); diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs new file mode 100644 index 00000000..3efa8d26 --- /dev/null +++ b/crates/organize/src/xmp.rs @@ -0,0 +1,251 @@ +//! Keeping the catalog's XMP metadata (`/Root /Metadata`, ISO 32000-2 §14.3.2) in step with the +//! document information when Document Properties ▸ Description changes it. +//! +//! Files that carry XMP state the title and author twice, and viewers that read XMP first show +//! the packet's value, so changing only `/Info` leaves the file disagreeing with itself. Each +//! information key maps to the XMP property PDF/A pairs it with (ISO 19005-1 §6.7.3, the same +//! mapping `pdfcraft-preflight` writes): Title → `dc:title`, Author → `dc:creator`, Subject → +//! `dc:description`, Keywords → `pdf:Keywords`, Creator → `xmp:CreatorTool`, Producer → +//! `pdf:Producer`. +//! +//! The packet is edited as text, touching only the property that changed: everything else in it +//! (other schemas, padding, unknown properties) stays byte for byte. A file without XMP gets +//! none, and a packet this can't edit safely (not UTF-8, no `rdf:RDF`, too large to decode) is +//! left as it was. + +use pdfcraft_cos::{Document, Object, Stream}; + +use crate::OrganizeError; +use crate::pdfx::esc; + +/// Decoding limit for an XMP packet (they are a few kB; padding rarely takes them past 100 kB). +const MAX_XMP: usize = 16 << 20; + +const DC: &str = "http://purl.org/dc/elements/1.1/"; +const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; +const XMP: &str = "http://ns.adobe.com/xap/1.0/"; + +/// How an XMP property holds its value. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Shape { + /// A language alternative: `rdf:Alt` with an `x-default` item. + LangAlt, + /// An ordered array: `rdf:Seq` (the authors). + Seq, + /// Plain text. + Text, +} + +/// The XMP property (qualified name, namespace) that mirrors a document information key. +fn property(key: &str) -> Option<(&'static str, &'static str, Shape)> { + Some(match key { + "Title" => ("dc:title", DC, Shape::LangAlt), + "Author" => ("dc:creator", DC, Shape::Seq), + "Subject" => ("dc:description", DC, Shape::LangAlt), + "Keywords" => ("pdf:Keywords", PDF, Shape::Text), + "Creator" => ("xmp:CreatorTool", XMP, Shape::Text), + "Producer" => ("pdf:Producer", PDF, Shape::Text), + _ => return None, + }) +} + +/// After the information entry `key` became `value` (empty: removed), make the catalog's XMP +/// packet say the same. Does nothing for keys XMP doesn't mirror, files without XMP, and packets +/// that can't be edited safely. +pub(crate) fn sync_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { + let Some((name, ns, shape)) = property(key) else { return Ok(()) }; + let Some(root) = doc.root() else { return Ok(()) }; + // A metadata stream is always indirect (§7.3.8); anything else is left alone. + let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"Metadata")) else { return Ok(()) }; + let obj = doc.get(r); + let Object::Stream(stream) = &*obj else { return Ok(()) }; + let Ok(bytes) = stream.decoded_within(MAX_XMP) else { return Ok(()) }; + let Ok(xmp) = String::from_utf8(bytes) else { return Ok(()) }; + let Some(updated) = set_property(&xmp, name, ns, shape, value) else { return Ok(()) }; + // Written unfiltered, as PDF/A asks of metadata streams, so other tools can read it. + let mut dict = stream.dict.clone(); + for k in [&b"Filter"[..], b"DecodeParms", b"DL", b"Length"] { + dict.remove(k); + } + doc.set(r, Object::Stream(Stream::from_raw(dict, updated.into_bytes()))); + Ok(()) +} + +/// `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 { + let element = (!value.is_empty()).then(|| match shape { + Shape::LangAlt => format!("<{name}>{}", esc(value)), + Shape::Seq => format!("<{name}>{}", esc(value)), + Shape::Text => format!("<{name}>{}", esc(value)), + }); + // The element form, `…` (or ``): replaced where it stands, so + // it keeps the namespace declaration of the description around it. + if let Some((start, end)) = element_span(xmp, name) { + let mut out = String::with_capacity(xmp.len()); + out.push_str(xmp.get(..start)?); + out.push_str(element.as_deref().unwrap_or_default()); + out.push_str(xmp.get(end..)?); + return Some(out); + } + // The attribute form, `pdf:Keywords="…"` on an `rdf:Description`: only simple text can be + // written there; a structured value moves to an element of its own. + if let Some((start, value_start, value_end, quote)) = attribute_span(xmp, name) { + let mut out = String::with_capacity(xmp.len()); + if shape == Shape::Text && !value.is_empty() { + let escaped = if quote == '"' { esc(value) } else { esc(value).replace('\'', "'") }; + out.push_str(xmp.get(..value_start)?); + out.push_str(&escaped); + out.push_str(xmp.get(value_end..)?); + return Some(out); + } + out.push_str(xmp.get(..start)?); + out.push_str(xmp.get(value_end + 1..)?); + return match element { + Some(e) => insert_description(&out, name, ns, &e), + None => Some(out), + }; + } + insert_description(xmp, name, ns, element.as_deref()?) +} + +/// `xmp` with a new `rdf:Description` holding `element` (and declaring its namespace) as the last +/// description of the packet. +fn insert_description(xmp: &str, name: &str, ns: &str, element: &str) -> Option { + let prefix = name.split(':').next()?; + let at = xmp.rfind("")?; + // Every description of a packet describes the same resource (XMP: one `rdf:about` value). + let about = attribute_span(xmp, "rdf:about").and_then(|(_, s, e, _)| xmp.get(s..e)).unwrap_or_default().replace('"', """); + let mut out = String::with_capacity(xmp.len() + element.len() + 128); + out.push_str(xmp.get(..at)?); + out.push_str(&format!("{element}\n")); + out.push_str(xmp.get(at..)?); + Some(out) +} + +/// Where the element `name` is in `xmp`, from its `<` to past its end tag (or past `/>`). +fn element_span(xmp: &str, name: &str) -> Option<(usize, usize)> { + let open = format!("<{name}"); + let mut from = 0; + while let Some(i) = xmp.get(from..).and_then(|s| s.find(&open)).map(|i| i + from) { + let after = i + open.len(); + from = after; + // `' | '/') => {} + Some(c) if c.is_whitespace() => {} + _ => continue, + } + let tag_end = after + xmp.get(after..)?.find('>')?; + if xmp.get(..tag_end)?.ends_with('/') { + return Some((i, tag_end + 1)); + } + let close = format!(""); + let end = tag_end + xmp.get(tag_end..)?.find(&close)? + close.len(); + return Some((i, end)); + } + None +} + +/// Where the attribute `name` is in `xmp`: its start (including the whitespace before it), the +/// start and end of its value, and its quote character. Only matches inside a start tag. +fn attribute_span(xmp: &str, name: &str) -> Option<(usize, usize, usize, char)> { + let mut from = 0; + while let Some(i) = xmp.get(from..).and_then(|s| s.find(name)).map(|i| i + from) { + from = i + name.len(); + let before = xmp.get(..i)?; + // Preceded by whitespace, and inside a tag (the last `<` comes after the last `>`). + if !before.ends_with(char::is_whitespace) || before.rfind('<') < before.rfind('>') || before.rfind('<').is_none() { + continue; + } + let rest = xmp.get(from..)?; + let trimmed = rest.trim_start(); + let Some(after_eq) = trimmed.strip_prefix('=') else { continue }; + let after_eq = after_eq.trim_start(); + let Some(quote) = after_eq.chars().next().filter(|c| matches!(c, '"' | '\'')) else { continue }; + let value_start = xmp.len() - after_eq.len() + 1; + let value_end = value_start + xmp.get(value_start..)?.find(quote)?; + let start = before.trim_end().len(); + return Some((start, value_start, value_end, quote)); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The shape of a LibreOffice export: one description per schema, element forms. + const PACKET: &str = "\n\ +\n \n\ + \n Writer 1.0\n \n\ + \n\ + Untitled 1\n\ + Old Author\n\ + Old subject\n\ + \n \n\n"; + + #[test] + fn element_values_are_replaced_in_place() { + let out = set_property(PACKET, "dc:title", DC, Shape::LangAlt, "Guide & ").unwrap(); + assert!(out.contains("Guide & <draft>\n"), "{out}"); + assert!(!out.contains("Untitled 1")); + // Everything else is untouched. + assert_eq!(out.replace("Guide & <draft>", "Untitled 1"), PACKET); + let out = set_property(PACKET, "dc:creator", DC, Shape::Seq, "Demo Consulting").unwrap(); + assert!(out.contains("Demo Consulting"), "{out}"); + } + + #[test] + fn an_empty_value_removes_the_property() { + let out = set_property(PACKET, "dc:description", DC, Shape::LangAlt, "").unwrap(); + assert!(!out.contains("dc:description"), "{out}"); + assert!(out.contains("Untitled 1") && out.contains("Old Author")); + // Nothing to remove: no change. + assert_eq!(set_property(PACKET, "pdf:Keywords", PDF, Shape::Text, ""), None); + } + + #[test] + fn a_missing_property_gets_a_description_of_its_own() { + let out = set_property(PACKET, "pdf:Keywords", PDF, Shape::Text, "alpha, beta").unwrap(); + let added = + "alpha, beta\n"; + assert!(out.find(added).is_some_and(|at| at > out.find("").unwrap() && at < out.find("").unwrap()), "{out}"); + assert_eq!(out.replacen(added, "", 1), PACKET); + // A packet with no rdf:RDF to add to is left alone. + assert_eq!(set_property("", "pdf:Keywords", PDF, Shape::Text, "alpha"), None); + } + + #[test] + fn attribute_values_are_rewritten_where_they_stand() { + let packet = ""; + let out = set_property(packet, "xmp:CreatorTool", XMP, Shape::Text, "Writer's tool").unwrap(); + assert!(out.contains("xmp:CreatorTool='Writer's tool'"), "{out}"); + // A title can't be an attribute: it moves to an element, in a description for the same resource. + let out = set_property(packet, "dc:title", DC, Shape::LangAlt, "Guide").unwrap(); + assert!(!out.contains("dc:title=\"Odd\""), "{out}"); + assert!(out.contains(""), "{out}"); + let out = set_property(packet, "xmp:CreatorTool", XMP, Shape::Text, "").unwrap(); + assert!(out.contains("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" dc:title=\"Odd\"/>"), "{out}"); + } + + #[test] + fn lookalike_names_and_text_are_not_matched() { + let packet = "keep\ + say dc:title=\"no\""; + let out = set_property(packet, "dc:title", DC, Shape::LangAlt, "New").unwrap(); + assert!(out.contains("keep") && out.contains("say dc:title=\"no\""), "{out}"); + assert!(out.contains("New"), "{out}"); + } + + #[test] + fn hostile_packets_do_not_panic() { + for packet in ["", "<", "", "