mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 04:58:34 -06:00
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:
co-authored by
Claude Opus 5.5
parent
39844f06b5
commit
6996aac44a
+27
-10
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 & “notes”</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 & 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.
|
||||
|
||||
@@ -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 & b <c> AB &bogus; � & end"), "a & b <c> 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 = "<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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user