use std::borrow::Cow;
use zpdf_core::PdfObject;
use zpdf_parser::PdfFile;
use crate::obj_util::catalog_dict;
const MAX_XMP_BYTES: usize = 8 * 1024 * 1024;
const MAX_FIELD_LEN: usize = 8192;
const MAX_LI: usize = 1024;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct XmpMetadata {
pub title: Option<String>,
pub creators: Vec<String>,
pub description: Option<String>,
pub subjects: Vec<String>,
pub keywords: Option<String>,
pub producer: Option<String>,
pub creator_tool: Option<String>,
pub create_date: Option<String>,
pub modify_date: Option<String>,
}
impl XmpMetadata {
pub fn is_empty(&self) -> bool {
self.title.is_none()
&& self.creators.is_empty()
&& self.description.is_none()
&& self.subjects.is_empty()
&& self.keywords.is_none()
&& self.producer.is_none()
&& self.creator_tool.is_none()
&& self.create_date.is_none()
&& self.modify_date.is_none()
}
}
pub fn metadata_bytes(file: &PdfFile) -> Option<Vec<u8>> {
let root = catalog_dict(file)?;
let id = match root.get("Metadata")? {
PdfObject::Ref(r) => *r,
_ => return None,
};
file.resolve_stream_data(id).ok()
}
pub fn parse_xmp(file: &PdfFile) -> Option<XmpMetadata> {
let bytes = metadata_bytes(file)?;
let xml = decode_text(&bytes);
let meta = scrape(&xml);
if meta.is_empty() {
None
} else {
Some(meta)
}
}
fn decode_text(bytes: &[u8]) -> String {
let bytes = &bytes[..bytes.len().min(MAX_XMP_BYTES)];
if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
decode_utf16(rest, true)
} else if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
decode_utf16(rest, false)
} else {
let rest = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes);
String::from_utf8_lossy(rest).into_owned()
}
}
fn decode_utf16(bytes: &[u8], be: bool) -> String {
let units: Vec<u16> = bytes
.chunks_exact(2)
.map(|c| {
if be {
u16::from_be_bytes([c[0], c[1]])
} else {
u16::from_le_bytes([c[0], c[1]])
}
})
.collect();
String::from_utf16_lossy(&units)
}
fn scrape(xml: &str) -> XmpMetadata {
let xml = strip_comments(xml);
XmpMetadata {
title: alt_property(&xml, "dc:title"),
creators: array_property(&xml, "dc:creator"),
description: alt_property(&xml, "dc:description"),
subjects: array_property(&xml, "dc:subject"),
keywords: simple_property(&xml, "pdf:Keywords"),
producer: simple_property(&xml, "pdf:Producer"),
creator_tool: simple_property(&xml, "xmp:CreatorTool"),
create_date: simple_property(&xml, "xmp:CreateDate"),
modify_date: simple_property(&xml, "xmp:ModifyDate"),
}
}
fn strip_comments(xml: &str) -> Cow<'_, str> {
if !xml.contains("<!--") {
return Cow::Borrowed(xml);
}
let mut out = String::with_capacity(xml.len());
let mut rest = xml;
while let Some(start) = rest.find("<!--") {
out.push_str(&rest[..start]);
let after = &rest[start + 4..];
match after.find("-->") {
Some(end) => rest = &after[end + 3..],
None => {
rest = "";
break;
}
}
}
out.push_str(rest);
Cow::Owned(out)
}
fn simple_property(xml: &str, qname: &str) -> Option<String> {
element_inner(xml, qname)
.and_then(simple_text)
.or_else(|| attribute_value(xml, qname))
}
fn alt_property(xml: &str, qname: &str) -> Option<String> {
if let Some(inner) = element_inner(xml, qname) {
let lis = scan_li(inner);
if let Some(pick) = lis.iter().find(|l| l.x_default).or_else(|| lis.first()) {
return Some(pick.value.clone());
}
if let Some(t) = simple_text(inner) {
return Some(t);
}
}
attribute_value(xml, qname)
}
fn array_property(xml: &str, qname: &str) -> Vec<String> {
if let Some(inner) = element_inner(xml, qname) {
let lis = scan_li(inner);
if !lis.is_empty() {
return lis.into_iter().map(|l| l.value).collect();
}
if let Some(t) = simple_text(inner) {
return vec![t];
}
}
attribute_value(xml, qname).into_iter().collect()
}
struct Li {
x_default: bool,
value: String,
}
fn scan_li(inner: &str) -> Vec<Li> {
const CLOSE: &str = "</rdf:li>";
let mut out = Vec::new();
let mut rest = inner;
while out.len() < MAX_LI {
let Some(start) = find_open_tag(rest, "rdf:li") else {
break;
};
let after = &rest[start..];
let Some(gt) = after.find('>') else {
break;
};
let open = &after[..gt]; let x_default = open.contains("x-default");
if open.ends_with('/') {
rest = &after[gt + 1..];
continue;
}
let content_start = gt + 1;
let Some(close_rel) = after[content_start..].find(CLOSE) else {
break;
};
let value = decode_entities(after[content_start..content_start + close_rel].trim());
if !value.is_empty() {
out.push(Li { x_default, value });
}
rest = &after[content_start + close_rel + CLOSE.len()..];
}
out
}
fn simple_text(inner: &str) -> Option<String> {
let trimmed = inner.trim();
if trimmed.is_empty() || trimmed.contains('<') {
return None;
}
let t = decode_entities(trimmed);
(!t.is_empty()).then_some(t)
}
fn element_inner<'a>(xml: &'a str, qname: &str) -> Option<&'a str> {
let open = find_open_tag(xml, qname)?;
let after = &xml[open..];
let gt = after.find('>')?;
if after[..gt].ends_with('/') {
return Some("");
}
let content_start = gt + 1;
let close = format!("</{qname}>");
let rel = after[content_start..].find(&close)?;
Some(&after[content_start..content_start + rel])
}
fn find_open_tag(xml: &str, qname: &str) -> Option<usize> {
let needle_buf = format!("<{qname}");
let needle = needle_buf.as_str();
let mut from = 0;
while let Some(rel) = xml[from..].find(needle) {
let pos = from + rel;
let after_idx = pos + needle.len();
match xml.as_bytes().get(after_idx) {
Some(b' ' | b'\t' | b'\r' | b'\n' | b'>' | b'/') => return Some(pos),
None => return None,
_ => from = after_idx,
}
}
None
}
fn attribute_value(xml: &str, qname: &str) -> Option<String> {
let mut from = 0;
while let Some(rel) = xml[from..].find(qname) {
let pos = from + rel;
let prev_ok = pos == 0
|| matches!(
xml.as_bytes()[pos - 1],
b' ' | b'\t' | b'\r' | b'\n' | b'<' | b'"' | b'\''
);
let after = pos + qname.len();
if prev_ok {
let rest = xml[after..].trim_start();
if let Some(rest) = rest.strip_prefix('=') {
let rest = rest.trim_start();
let mut chars = rest.chars();
if let Some(q @ ('"' | '\'')) = chars.next() {
let body = &rest[q.len_utf8()..];
if let Some(end) = body.find(q) {
return Some(decode_entities(&body[..end]));
}
}
}
}
from = after;
}
None
}
fn decode_entities(s: &str) -> String {
let s = cap_len(s);
if !s.contains('&') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
let tail = &rest[amp..];
let mut wend = tail.len().min(12);
while wend > 0 && !tail.is_char_boundary(wend) {
wend -= 1;
}
let window = &tail[..wend];
if let Some(semi) = window.find(';') {
if let Some(ch) = decode_one_entity(&tail[1..semi]) {
out.push(ch);
rest = &tail[semi + 1..];
continue;
}
}
out.push('&');
rest = &tail[1..];
}
out.push_str(rest);
out
}
fn decode_one_entity(body: &str) -> Option<char> {
match body {
"lt" => Some('<'),
"gt" => Some('>'),
"amp" => Some('&'),
"quot" => Some('"'),
"apos" => Some('\''),
_ => {
let num = body.strip_prefix('#')?;
let code = match num.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
None => num.parse::<u32>().ok()?,
};
let ch = char::from_u32(code)?;
is_xml_char(ch).then_some(ch)
}
}
}
fn is_xml_char(ch: char) -> bool {
matches!(ch,
'\u{09}' | '\u{0A}' | '\u{0D}'
| '\u{20}'..='\u{7E}'
| '\u{85}'
| '\u{00A0}'..='\u{D7FF}'
| '\u{E000}'..='\u{FFFD}'
| '\u{10000}'..='\u{10FFFF}'
)
}
fn cap_len(s: &str) -> &str {
if s.len() <= MAX_FIELD_LEN {
return s;
}
let mut end = MAX_FIELD_LEN;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
#[cfg(test)]
mod tests {
use super::*;
const DC_RDF: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<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/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:pdf="http://ns.adobe.com/pdf/1.3/"
pdf:Producer="Acrobat 7.0">
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">Annual & Report</rdf:li></rdf:Alt></dc:title>
<dc:creator><rdf:Seq><rdf:li>Jane Doe</rdf:li><rdf:li>John Roe</rdf:li></rdf:Seq></dc:creator>
<dc:subject><rdf:Bag><rdf:li>finance</rdf:li><rdf:li>q4</rdf:li></rdf:Bag></dc:subject>
<xmp:CreatorTool>LibreOffice</xmp:CreatorTool>
<xmp:CreateDate>2024-01-01T12:00:00Z</xmp:CreateDate>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
#[test]
fn scrapes_standard_packet() {
let m = scrape(DC_RDF);
assert_eq!(m.title.as_deref(), Some("Annual & Report")); assert_eq!(m.creators, vec!["Jane Doe", "John Roe"]);
assert_eq!(m.subjects, vec!["finance", "q4"]);
assert_eq!(m.creator_tool.as_deref(), Some("LibreOffice"));
assert_eq!(m.create_date.as_deref(), Some("2024-01-01T12:00:00Z"));
assert_eq!(m.producer.as_deref(), Some("Acrobat 7.0"));
assert!(!m.is_empty());
}
#[test]
fn x_default_language_preferred() {
let xml = r#"<dc:title><rdf:Alt>
<rdf:li xml:lang="fr">Bonjour</rdf:li>
<rdf:li xml:lang="x-default">Hello</rdf:li>
</rdf:Alt></dc:title>"#;
assert_eq!(alt_property(xml, "dc:title").as_deref(), Some("Hello"));
}
#[test]
fn first_li_when_no_x_default() {
let xml =
r#"<dc:title><rdf:Alt><rdf:li xml:lang="fr">Bonjour</rdf:li></rdf:Alt></dc:title>"#;
assert_eq!(alt_property(xml, "dc:title").as_deref(), Some("Bonjour"));
}
#[test]
fn simple_element_property() {
let xml = "<pdf:Producer>A & B <v2></pdf:Producer>";
assert_eq!(
simple_property(xml, "pdf:Producer").as_deref(),
Some("A & B <v2>")
);
}
#[test]
fn numeric_character_reference_decoded() {
let xml = "<pdf:Producer>Acme © ™</pdf:Producer>";
assert_eq!(
simple_property(xml, "pdf:Producer").as_deref(),
Some("Acme © ™")
);
}
#[test]
fn open_tag_requires_delimiter() {
let xml = "<dc:titlebar>nope</dc:titlebar><dc:title>yes</dc:title>";
assert_eq!(simple_property(xml, "dc:title").as_deref(), Some("yes"));
}
#[test]
fn unknown_entity_is_not_expanded() {
let xml = "<pdf:Producer>&lol9; tail</pdf:Producer>";
let v = simple_property(xml, "pdf:Producer").expect("value");
assert_eq!(v, "&lol9; tail");
}
#[test]
fn long_value_is_length_capped() {
let big = "x".repeat(MAX_FIELD_LEN * 4);
let xml = format!("<pdf:Producer>{big}</pdf:Producer>");
let v = simple_property(&xml, "pdf:Producer").expect("value");
assert!(v.len() <= MAX_FIELD_LEN, "value must be capped");
}
#[test]
fn missing_property_is_none() {
assert!(simple_property(DC_RDF, "pdf:Keywords").is_none());
assert!(scrape("<x>no xmp here</x>").is_empty());
}
#[test]
fn numeric_ref_to_control_char_is_rejected() {
let xml = "<pdf:Producer>a�bc ©</pdf:Producer>";
let v = simple_property(xml, "pdf:Producer").expect("value");
assert!(!v.contains('\u{0}'), "NUL must not be injected");
assert!(!v.contains('\u{1}'), "control char must not be injected");
assert!(v.contains('\u{A9}'), "valid char ref still decodes");
}
#[test]
fn commented_out_property_is_ignored() {
let xml = "<rdf:Description>\
<!-- <pdf:Producer>FAKE</pdf:Producer> -->\
<pdf:Producer>REAL</pdf:Producer></rdf:Description>";
assert_eq!(scrape(xml).producer.as_deref(), Some("REAL"));
}
#[test]
fn empty_container_does_not_leak_markup() {
assert!(alt_property("<dc:title><rdf:Alt></rdf:Alt></dc:title>", "dc:title").is_none());
assert!(array_property("<dc:creator><rdf:Seq/></dc:creator>", "dc:creator").is_empty());
assert_eq!(
alt_property("<dc:title>Plain</dc:title>", "dc:title").as_deref(),
Some("Plain")
);
}
#[test]
fn multibyte_near_entity_window_does_not_panic() {
let xml = "<pdf:Producer>&xxxxxxxxxx\u{20AC}more</pdf:Producer>";
let v = simple_property(xml, "pdf:Producer").expect("value");
assert!(v.contains("more")); }
#[test]
fn attribute_value_with_multibyte_is_decoded() {
let xml = "<rdf:Description pdf:Producer=\"\u{20AC}x\">";
assert_eq!(
attribute_value(xml, "pdf:Producer").as_deref(),
Some("\u{20AC}x")
);
}
#[test]
fn utf16be_bom_decodes() {
let s = "<pdf:Producer>Hi</pdf:Producer>";
let mut bytes = vec![0xFE, 0xFF];
for u in s.encode_utf16() {
bytes.extend_from_slice(&u.to_be_bytes());
}
let xml = decode_text(&bytes);
assert_eq!(simple_property(&xml, "pdf:Producer").as_deref(), Some("Hi"));
}
}