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 <noreply@anthropic.com>
This commit is contained in:
magdale76
2026-10-22 23:33:26 +02:00
co-authored by Claude Opus 5.5
parent 39844f06b5
commit 6996aac44a
6 changed files with 229 additions and 15 deletions
+27 -10
View File
@@ -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<String>, Option<String>); 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<String>,
bytes: Arc<Vec<u8>>,
info: DocInfo,
mut info: DocInfo,
cos: Result<Result<pdfcraft_cos::Document, pdfcraft_cos::CosError>, Box<dyn std::any::Any + Send>>,
render_password: Option<String>,
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)]
+24
View File
@@ -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 = "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\
<rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\
<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">Quarterly report</rdf:li></rdf:Alt></dc:title>\
<dc:creator><rdf:Seq><rdf:li>Ada</rdf:li></rdf:Seq></dc:creator></rdf:Description></rdf:RDF></x:xmpmeta>";
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);
+9 -3
View File
@@ -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<String> {
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.
+37
View File
@@ -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(
"<rdf:li xml:lang=\"x-default\">Untitled 1</rdf:li>",
"<rdf:li xml:lang=\"de\">Bericht</rdf:li><rdf:li xml:lang=\"x-default\">Report &amp; &#x201C;notes&#8221;</rdf:li>",
)
.replace("<rdf:li>Old Author</rdf:li>", "<rdf:li>Ada</rdf:li>\n<rdf:li>Grace</rdf:li>")
.replace("<pdf:Producer>Writer 1.0</pdf:Producer>", "<pdf:Producer>Writer 1.0</pdf:Producer><pdf:Keywords/>");
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 = "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\
<rdf:Description rdf:about=\"\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\" pdf:Keywords='a, b &amp; c'/></rdf:RDF></x:xmpmeta>";
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"<!-- \xE9 -->");
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.
+130
View File
@@ -92,6 +92,120 @@ fn edit_packet(doc: &mut Document, edit: impl FnOnce(&str) -> Option<String>) ->
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<String> {
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<String> {
let value = if let Some((start, end)) = element_span(xmp, name) {
let element = xmp.get(start..end)?;
let open_end = element.find('>')?;
// `<dc:title/>`: no value.
if element.get(..open_end)?.ends_with('/') {
return None;
}
let inner = element.get(open_end + 1..)?.strip_suffix(&format!("</{name}>"))?;
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::<Vec<_>>().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<String>, &str)> {
let mut items = Vec::new();
let mut rest = inner;
while let Some(i) = rest.find("<rdf:li") {
let Some(after) = rest.get(i + "<rdf:li".len()..) else { break };
// `<rdf:li` must end there: not `<rdf:link`.
if !after.starts_with(|c: char| c == '>' || 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("</rdf:li>") 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<String> {
@@ -206,6 +320,22 @@ mod tests {
<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">Old subject</rdf:li></rdf:Alt></dc:description>\n\
</rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>";
#[test]
fn values_are_read_back_unescaped() {
assert_eq!(unescape("a &amp; b &lt;c&gt; &#65;&#x42; &bogus; &#xD800; & end"), "a & b <c> AB &bogus; &#xD800; & 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 = "<dc:title><rdf:Alt><rdf:li xml:lang='fr'>Rapport</rdf:li><rdf:li xml:lang='en'>Report</rdf:li></rdf:Alt></dc:title>";
assert_eq!(get_property(alt, "dc:title", Shape::LangAlt).as_deref(), Some("Rapport"));
let seq = "<dc:creator><rdf:Seq><rdf:li/><rdf:li> </rdf:li><rdf:li>Ada</rdf:li></rdf:Seq></dc:creator>";
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("<dc:title><rdf:Alt><rdf:li>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 & <draft>").unwrap();
+2 -2
View File
@@ -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"