1use std::borrow::Cow;
22
23use zpdf_core::PdfObject;
24use zpdf_parser::PdfFile;
25
26use crate::obj_util::catalog_dict;
27
28const MAX_XMP_BYTES: usize = 8 * 1024 * 1024;
31const MAX_FIELD_LEN: usize = 8192;
33const MAX_LI: usize = 1024;
35
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct XmpMetadata {
41 pub title: Option<String>,
43 pub creators: Vec<String>,
45 pub description: Option<String>,
47 pub subjects: Vec<String>,
49 pub keywords: Option<String>,
51 pub producer: Option<String>,
53 pub creator_tool: Option<String>,
55 pub create_date: Option<String>,
57 pub modify_date: Option<String>,
59}
60
61impl XmpMetadata {
62 pub fn is_empty(&self) -> bool {
64 self.title.is_none()
65 && self.creators.is_empty()
66 && self.description.is_none()
67 && self.subjects.is_empty()
68 && self.keywords.is_none()
69 && self.producer.is_none()
70 && self.creator_tool.is_none()
71 && self.create_date.is_none()
72 && self.modify_date.is_none()
73 }
74}
75
76pub fn metadata_bytes(file: &PdfFile) -> Option<Vec<u8>> {
80 let root = catalog_dict(file)?;
81 let id = match root.get("Metadata")? {
84 PdfObject::Ref(r) => *r,
85 _ => return None,
86 };
87 file.resolve_stream_data(id).ok()
88}
89
90pub fn parse_xmp(file: &PdfFile) -> Option<XmpMetadata> {
94 let bytes = metadata_bytes(file)?;
95 let xml = decode_text(&bytes);
96 let meta = scrape(&xml);
97 if meta.is_empty() {
98 None
99 } else {
100 Some(meta)
101 }
102}
103
104fn decode_text(bytes: &[u8]) -> String {
108 let bytes = &bytes[..bytes.len().min(MAX_XMP_BYTES)];
109 if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
110 decode_utf16(rest, true)
111 } else if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
112 decode_utf16(rest, false)
113 } else {
114 let rest = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes);
115 String::from_utf8_lossy(rest).into_owned()
116 }
117}
118
119fn decode_utf16(bytes: &[u8], be: bool) -> String {
121 let units: Vec<u16> = bytes
122 .chunks_exact(2)
123 .map(|c| {
124 if be {
125 u16::from_be_bytes([c[0], c[1]])
126 } else {
127 u16::from_le_bytes([c[0], c[1]])
128 }
129 })
130 .collect();
131 String::from_utf16_lossy(&units)
132}
133
134fn scrape(xml: &str) -> XmpMetadata {
137 let xml = strip_comments(xml);
138 XmpMetadata {
139 title: alt_property(&xml, "dc:title"),
140 creators: array_property(&xml, "dc:creator"),
141 description: alt_property(&xml, "dc:description"),
142 subjects: array_property(&xml, "dc:subject"),
143 keywords: simple_property(&xml, "pdf:Keywords"),
144 producer: simple_property(&xml, "pdf:Producer"),
145 creator_tool: simple_property(&xml, "xmp:CreatorTool"),
146 create_date: simple_property(&xml, "xmp:CreateDate"),
147 modify_date: simple_property(&xml, "xmp:ModifyDate"),
148 }
149}
150
151fn strip_comments(xml: &str) -> Cow<'_, str> {
156 if !xml.contains("<!--") {
157 return Cow::Borrowed(xml);
158 }
159 let mut out = String::with_capacity(xml.len());
160 let mut rest = xml;
161 while let Some(start) = rest.find("<!--") {
162 out.push_str(&rest[..start]);
163 let after = &rest[start + 4..];
164 match after.find("-->") {
165 Some(end) => rest = &after[end + 3..],
166 None => {
167 rest = "";
168 break;
169 }
170 }
171 }
172 out.push_str(rest);
173 Cow::Owned(out)
174}
175
176fn simple_property(xml: &str, qname: &str) -> Option<String> {
178 element_inner(xml, qname)
179 .and_then(simple_text)
180 .or_else(|| attribute_value(xml, qname))
181}
182
183fn alt_property(xml: &str, qname: &str) -> Option<String> {
186 if let Some(inner) = element_inner(xml, qname) {
187 let lis = scan_li(inner);
188 if let Some(pick) = lis.iter().find(|l| l.x_default).or_else(|| lis.first()) {
189 return Some(pick.value.clone());
190 }
191 if let Some(t) = simple_text(inner) {
192 return Some(t);
193 }
194 }
195 attribute_value(xml, qname)
196}
197
198fn array_property(xml: &str, qname: &str) -> Vec<String> {
201 if let Some(inner) = element_inner(xml, qname) {
202 let lis = scan_li(inner);
203 if !lis.is_empty() {
204 return lis.into_iter().map(|l| l.value).collect();
205 }
206 if let Some(t) = simple_text(inner) {
207 return vec![t];
208 }
209 }
210 attribute_value(xml, qname).into_iter().collect()
211}
212
213struct Li {
215 x_default: bool,
216 value: String,
217}
218
219fn scan_li(inner: &str) -> Vec<Li> {
223 const CLOSE: &str = "</rdf:li>";
224 let mut out = Vec::new();
225 let mut rest = inner;
226 while out.len() < MAX_LI {
227 let Some(start) = find_open_tag(rest, "rdf:li") else {
228 break;
229 };
230 let after = &rest[start..];
231 let Some(gt) = after.find('>') else {
232 break;
233 };
234 let open = &after[..gt]; let x_default = open.contains("x-default");
236 if open.ends_with('/') {
237 rest = &after[gt + 1..];
239 continue;
240 }
241 let content_start = gt + 1;
242 let Some(close_rel) = after[content_start..].find(CLOSE) else {
243 break;
244 };
245 let value = decode_entities(after[content_start..content_start + close_rel].trim());
246 if !value.is_empty() {
247 out.push(Li { x_default, value });
248 }
249 rest = &after[content_start + close_rel + CLOSE.len()..];
250 }
251 out
252}
253
254fn simple_text(inner: &str) -> Option<String> {
261 let trimmed = inner.trim();
262 if trimmed.is_empty() || trimmed.contains('<') {
263 return None;
264 }
265 let t = decode_entities(trimmed);
266 (!t.is_empty()).then_some(t)
267}
268
269fn element_inner<'a>(xml: &'a str, qname: &str) -> Option<&'a str> {
273 let open = find_open_tag(xml, qname)?;
274 let after = &xml[open..];
275 let gt = after.find('>')?;
276 if after[..gt].ends_with('/') {
277 return Some("");
278 }
279 let content_start = gt + 1;
280 let close = format!("</{qname}>");
281 let rel = after[content_start..].find(&close)?;
282 Some(&after[content_start..content_start + rel])
283}
284
285fn find_open_tag(xml: &str, qname: &str) -> Option<usize> {
288 let needle_buf = format!("<{qname}");
289 let needle = needle_buf.as_str();
290 let mut from = 0;
291 while let Some(rel) = xml[from..].find(needle) {
292 let pos = from + rel;
293 let after_idx = pos + needle.len();
294 match xml.as_bytes().get(after_idx) {
295 Some(b' ' | b'\t' | b'\r' | b'\n' | b'>' | b'/') => return Some(pos),
296 None => return None,
297 _ => from = after_idx,
299 }
300 }
301 None
302}
303
304fn attribute_value(xml: &str, qname: &str) -> Option<String> {
308 let mut from = 0;
309 while let Some(rel) = xml[from..].find(qname) {
310 let pos = from + rel;
311 let prev_ok = pos == 0
312 || matches!(
313 xml.as_bytes()[pos - 1],
314 b' ' | b'\t' | b'\r' | b'\n' | b'<' | b'"' | b'\''
315 );
316 let after = pos + qname.len();
317 if prev_ok {
318 let rest = xml[after..].trim_start();
319 if let Some(rest) = rest.strip_prefix('=') {
320 let rest = rest.trim_start();
321 let mut chars = rest.chars();
322 if let Some(q @ ('"' | '\'')) = chars.next() {
323 let body = &rest[q.len_utf8()..];
324 if let Some(end) = body.find(q) {
325 return Some(decode_entities(&body[..end]));
326 }
327 }
328 }
329 }
330 from = after;
331 }
332 None
333}
334
335fn decode_entities(s: &str) -> String {
341 let s = cap_len(s);
342 if !s.contains('&') {
343 return s.to_string();
344 }
345 let mut out = String::with_capacity(s.len());
346 let mut rest = s;
347 while let Some(amp) = rest.find('&') {
348 out.push_str(&rest[..amp]);
349 let tail = &rest[amp..];
350 let mut wend = tail.len().min(12);
354 while wend > 0 && !tail.is_char_boundary(wend) {
355 wend -= 1;
356 }
357 let window = &tail[..wend];
358 if let Some(semi) = window.find(';') {
359 if let Some(ch) = decode_one_entity(&tail[1..semi]) {
360 out.push(ch);
361 rest = &tail[semi + 1..];
362 continue;
363 }
364 }
365 out.push('&');
367 rest = &tail[1..];
368 }
369 out.push_str(rest);
370 out
371}
372
373fn decode_one_entity(body: &str) -> Option<char> {
380 match body {
381 "lt" => Some('<'),
382 "gt" => Some('>'),
383 "amp" => Some('&'),
384 "quot" => Some('"'),
385 "apos" => Some('\''),
386 _ => {
387 let num = body.strip_prefix('#')?;
388 let code = match num.strip_prefix(['x', 'X']) {
389 Some(hex) => u32::from_str_radix(hex, 16).ok()?,
390 None => num.parse::<u32>().ok()?,
391 };
392 let ch = char::from_u32(code)?;
393 is_xml_char(ch).then_some(ch)
394 }
395 }
396}
397
398fn is_xml_char(ch: char) -> bool {
403 matches!(ch,
404 '\u{09}' | '\u{0A}' | '\u{0D}'
405 | '\u{20}'..='\u{7E}'
406 | '\u{85}'
407 | '\u{00A0}'..='\u{D7FF}'
408 | '\u{E000}'..='\u{FFFD}'
409 | '\u{10000}'..='\u{10FFFF}'
410 )
411}
412
413fn cap_len(s: &str) -> &str {
415 if s.len() <= MAX_FIELD_LEN {
416 return s;
417 }
418 let mut end = MAX_FIELD_LEN;
419 while end > 0 && !s.is_char_boundary(end) {
420 end -= 1;
421 }
422 &s[..end]
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 const DC_RDF: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
430<x:xmpmeta xmlns:x="adobe:ns:meta/">
431 <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
432 <rdf:Description rdf:about=""
433 xmlns:dc="http://purl.org/dc/elements/1.1/"
434 xmlns:xmp="http://ns.adobe.com/xap/1.0/"
435 xmlns:pdf="http://ns.adobe.com/pdf/1.3/"
436 pdf:Producer="Acrobat 7.0">
437 <dc:title><rdf:Alt><rdf:li xml:lang="x-default">Annual & Report</rdf:li></rdf:Alt></dc:title>
438 <dc:creator><rdf:Seq><rdf:li>Jane Doe</rdf:li><rdf:li>John Roe</rdf:li></rdf:Seq></dc:creator>
439 <dc:subject><rdf:Bag><rdf:li>finance</rdf:li><rdf:li>q4</rdf:li></rdf:Bag></dc:subject>
440 <xmp:CreatorTool>LibreOffice</xmp:CreatorTool>
441 <xmp:CreateDate>2024-01-01T12:00:00Z</xmp:CreateDate>
442 </rdf:Description>
443 </rdf:RDF>
444</x:xmpmeta>
445<?xpacket end="w"?>"#;
446
447 #[test]
448 fn scrapes_standard_packet() {
449 let m = scrape(DC_RDF);
450 assert_eq!(m.title.as_deref(), Some("Annual & Report")); assert_eq!(m.creators, vec!["Jane Doe", "John Roe"]);
452 assert_eq!(m.subjects, vec!["finance", "q4"]);
453 assert_eq!(m.creator_tool.as_deref(), Some("LibreOffice"));
454 assert_eq!(m.create_date.as_deref(), Some("2024-01-01T12:00:00Z"));
455 assert_eq!(m.producer.as_deref(), Some("Acrobat 7.0"));
457 assert!(!m.is_empty());
458 }
459
460 #[test]
461 fn x_default_language_preferred() {
462 let xml = r#"<dc:title><rdf:Alt>
463 <rdf:li xml:lang="fr">Bonjour</rdf:li>
464 <rdf:li xml:lang="x-default">Hello</rdf:li>
465 </rdf:Alt></dc:title>"#;
466 assert_eq!(alt_property(xml, "dc:title").as_deref(), Some("Hello"));
467 }
468
469 #[test]
470 fn first_li_when_no_x_default() {
471 let xml =
472 r#"<dc:title><rdf:Alt><rdf:li xml:lang="fr">Bonjour</rdf:li></rdf:Alt></dc:title>"#;
473 assert_eq!(alt_property(xml, "dc:title").as_deref(), Some("Bonjour"));
474 }
475
476 #[test]
477 fn simple_element_property() {
478 let xml = "<pdf:Producer>A & B <v2></pdf:Producer>";
479 assert_eq!(
480 simple_property(xml, "pdf:Producer").as_deref(),
481 Some("A & B <v2>")
482 );
483 }
484
485 #[test]
486 fn numeric_character_reference_decoded() {
487 let xml = "<pdf:Producer>Acme © ™</pdf:Producer>";
489 assert_eq!(
490 simple_property(xml, "pdf:Producer").as_deref(),
491 Some("Acme © ™")
492 );
493 }
494
495 #[test]
496 fn open_tag_requires_delimiter() {
497 let xml = "<dc:titlebar>nope</dc:titlebar><dc:title>yes</dc:title>";
499 assert_eq!(simple_property(xml, "dc:title").as_deref(), Some("yes"));
500 }
501
502 #[test]
503 fn unknown_entity_is_not_expanded() {
504 let xml = "<pdf:Producer>&lol9; tail</pdf:Producer>";
508 let v = simple_property(xml, "pdf:Producer").expect("value");
509 assert_eq!(v, "&lol9; tail");
510 }
511
512 #[test]
513 fn long_value_is_length_capped() {
514 let big = "x".repeat(MAX_FIELD_LEN * 4);
515 let xml = format!("<pdf:Producer>{big}</pdf:Producer>");
516 let v = simple_property(&xml, "pdf:Producer").expect("value");
517 assert!(v.len() <= MAX_FIELD_LEN, "value must be capped");
518 }
519
520 #[test]
521 fn missing_property_is_none() {
522 assert!(simple_property(DC_RDF, "pdf:Keywords").is_none());
523 assert!(scrape("<x>no xmp here</x>").is_empty());
524 }
525
526 #[test]
527 fn numeric_ref_to_control_char_is_rejected() {
528 let xml = "<pdf:Producer>a�bc ©</pdf:Producer>";
531 let v = simple_property(xml, "pdf:Producer").expect("value");
532 assert!(!v.contains('\u{0}'), "NUL must not be injected");
533 assert!(!v.contains('\u{1}'), "control char must not be injected");
534 assert!(v.contains('\u{A9}'), "valid char ref still decodes");
535 }
536
537 #[test]
538 fn commented_out_property_is_ignored() {
539 let xml = "<rdf:Description>\
541 <!-- <pdf:Producer>FAKE</pdf:Producer> -->\
542 <pdf:Producer>REAL</pdf:Producer></rdf:Description>";
543 assert_eq!(scrape(xml).producer.as_deref(), Some("REAL"));
544 }
545
546 #[test]
547 fn empty_container_does_not_leak_markup() {
548 assert!(alt_property("<dc:title><rdf:Alt></rdf:Alt></dc:title>", "dc:title").is_none());
551 assert!(array_property("<dc:creator><rdf:Seq/></dc:creator>", "dc:creator").is_empty());
552 assert_eq!(
554 alt_property("<dc:title>Plain</dc:title>", "dc:title").as_deref(),
555 Some("Plain")
556 );
557 }
558
559 #[test]
560 fn multibyte_near_entity_window_does_not_panic() {
561 let xml = "<pdf:Producer>&xxxxxxxxxx\u{20AC}more</pdf:Producer>";
565 let v = simple_property(xml, "pdf:Producer").expect("value");
566 assert!(v.contains("more")); }
568
569 #[test]
570 fn attribute_value_with_multibyte_is_decoded() {
571 let xml = "<rdf:Description pdf:Producer=\"\u{20AC}x\">";
574 assert_eq!(
575 attribute_value(xml, "pdf:Producer").as_deref(),
576 Some("\u{20AC}x")
577 );
578 }
579
580 #[test]
581 fn utf16be_bom_decodes() {
582 let s = "<pdf:Producer>Hi</pdf:Producer>";
584 let mut bytes = vec![0xFE, 0xFF];
585 for u in s.encode_utf16() {
586 bytes.extend_from_slice(&u.to_be_bytes());
587 }
588 let xml = decode_text(&bytes);
589 assert_eq!(simple_property(&xml, "pdf:Producer").as_deref(), Some("Hi"));
590 }
591}