1#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct XmpPacket {
39 pub dc_title: Option<String>,
43 pub dc_creator: Option<String>,
46 pub dc_description: Option<String>,
48 pub dc_subject: Vec<String>,
51 pub dc_rights: Option<String>,
53 pub dc_format: Option<String>,
55
56 pub xmp_create_date: Option<String>,
59 pub xmp_modify_date: Option<String>,
61 pub xmp_metadata_date: Option<String>,
64 pub xmp_creator_tool: Option<String>,
67
68 pub pdf_producer: Option<String>,
73 pub pdf_keywords: Option<String>,
76 pub pdf_version: Option<String>,
78 pub pdf_trapped: Option<String>,
81
82 pub pdfaid_part: Option<u8>,
86 pub pdfaid_conformance: Option<String>,
89}
90
91impl XmpPacket {
92 pub fn parse(bytes: &[u8]) -> Self {
96 let owned;
100 let s: &str = match std::str::from_utf8(bytes) {
101 Ok(s) => s,
102 Err(_) => {
103 owned = String::from_utf8_lossy(bytes).into_owned();
104 owned.as_str()
105 }
106 };
107
108 Self {
109 dc_title: extract_lang_alt(s, "dc:title").or_else(|| extract_attr(s, "dc:title")),
110 dc_creator: extract_seq_first(s, "dc:creator")
111 .or_else(|| extract_attr(s, "dc:creator")),
112 dc_description: extract_lang_alt(s, "dc:description")
113 .or_else(|| extract_attr(s, "dc:description")),
114 dc_subject: extract_bag(s, "dc:subject"),
115 dc_rights: extract_lang_alt(s, "dc:rights").or_else(|| extract_attr(s, "dc:rights")),
116 dc_format: extract_text(s, "dc:format").or_else(|| extract_attr(s, "dc:format")),
117
118 xmp_create_date: extract_text(s, "xmp:CreateDate")
119 .or_else(|| extract_attr(s, "xmp:CreateDate")),
120 xmp_modify_date: extract_text(s, "xmp:ModifyDate")
121 .or_else(|| extract_attr(s, "xmp:ModifyDate")),
122 xmp_metadata_date: extract_text(s, "xmp:MetadataDate")
123 .or_else(|| extract_attr(s, "xmp:MetadataDate")),
124 xmp_creator_tool: extract_text(s, "xmp:CreatorTool")
125 .or_else(|| extract_attr(s, "xmp:CreatorTool")),
126
127 pdf_producer: extract_text(s, "pdf:Producer")
128 .or_else(|| extract_attr(s, "pdf:Producer")),
129 pdf_keywords: extract_text(s, "pdf:Keywords")
130 .or_else(|| extract_attr(s, "pdf:Keywords")),
131 pdf_version: extract_text(s, "pdf:PDFVersion")
132 .or_else(|| extract_attr(s, "pdf:PDFVersion")),
133 pdf_trapped: extract_text(s, "pdf:Trapped").or_else(|| extract_attr(s, "pdf:Trapped")),
134
135 pdfaid_part: extract_text(s, "pdfaid:part")
136 .or_else(|| extract_attr(s, "pdfaid:part"))
137 .and_then(|v| v.trim().parse::<u8>().ok()),
138 pdfaid_conformance: extract_text(s, "pdfaid:conformance")
139 .or_else(|| extract_attr(s, "pdfaid:conformance"))
140 .map(|s| s.trim().to_string()),
141 }
142 }
143
144 pub fn is_pdf_a(&self) -> bool {
147 self.pdfaid_part.is_some()
148 }
149
150 pub fn pdf_a_conformance(&self) -> Option<String> {
153 Some(format!(
154 "{}{}",
155 self.pdfaid_part?,
156 self.pdfaid_conformance.as_deref()?
157 ))
158 }
159}
160
161fn extract_text(haystack: &str, tag: &str) -> Option<String> {
166 let open_pat = format!("<{}", tag);
169 let close_pat = format!("</{}>", tag);
170 let start_idx = haystack.find(&open_pat)?;
171 let after_open_name = start_idx + open_pat.len();
173 let next_byte = haystack.as_bytes().get(after_open_name)?;
175 if !is_tag_terminator(*next_byte) {
176 let rest_off = after_open_name;
178 let rest = &haystack[rest_off..];
179 if let Some(skip) = rest.find(&open_pat) {
180 return extract_text(&haystack[rest_off + skip..], tag);
181 }
182 return None;
183 }
184 let close_brace_off = haystack[after_open_name..].find('>')? + after_open_name;
185 if close_brace_off > 0 && haystack.as_bytes()[close_brace_off - 1] == b'/' {
187 return None;
188 }
189 let body_start = close_brace_off + 1;
190 let close_off = haystack[body_start..].find(&close_pat)? + body_start;
191 let body = &haystack[body_start..close_off];
192 Some(decode_entities(body.trim()))
193}
194
195fn extract_attr(haystack: &str, attr_name: &str) -> Option<String> {
200 let pat = format!("{}=\"", attr_name);
201 let mut search_from = 0usize;
202 while let Some(rel) = haystack[search_from..].find(&pat) {
203 let off = search_from + rel;
204 if off > 0 {
207 let prev = haystack.as_bytes()[off - 1];
208 if !prev.is_ascii_whitespace() && prev != b'<' {
209 search_from = off + pat.len();
210 continue;
211 }
212 }
213 let value_start = off + pat.len();
214 let value_end = haystack[value_start..].find('"')? + value_start;
215 return Some(decode_entities(&haystack[value_start..value_end]));
216 }
217 None
218}
219
220fn extract_lang_alt(haystack: &str, tag: &str) -> Option<String> {
227 let inner = extract_text(haystack, tag)?;
228 extract_first_li(&inner).or(Some(inner))
229}
230
231fn extract_bag(haystack: &str, tag: &str) -> Vec<String> {
234 let Some(inner) = extract_text(haystack, tag) else {
235 return Vec::new();
236 };
237 extract_all_li(&inner)
238}
239
240fn extract_seq_first(haystack: &str, tag: &str) -> Option<String> {
243 let inner = extract_text(haystack, tag)?;
244 extract_first_li(&inner).or(Some(inner))
245}
246
247fn extract_first_li(haystack: &str) -> Option<String> {
248 extract_text(haystack, "rdf:li")
249}
250
251fn extract_all_li(haystack: &str) -> Vec<String> {
252 let mut out = Vec::new();
253 let mut cursor = 0usize;
254 let open_pat = "<rdf:li";
255 let close_pat = "</rdf:li>";
256 while let Some(rel) = haystack[cursor..].find(open_pat) {
257 let open_off = cursor + rel;
258 let after_name = open_off + open_pat.len();
259 let next_byte = match haystack.as_bytes().get(after_name) {
260 Some(b) => *b,
261 None => break,
262 };
263 if !is_tag_terminator(next_byte) {
264 cursor = after_name;
265 continue;
266 }
267 let Some(close_brace_rel) = haystack[after_name..].find('>') else {
268 break;
269 };
270 let close_brace = after_name + close_brace_rel;
271 if close_brace > 0 && haystack.as_bytes()[close_brace - 1] == b'/' {
273 cursor = close_brace + 1;
274 continue;
275 }
276 let body_start = close_brace + 1;
277 let Some(close_rel) = haystack[body_start..].find(close_pat) else {
278 break;
279 };
280 let close_off = body_start + close_rel;
281 out.push(decode_entities(haystack[body_start..close_off].trim()));
282 cursor = close_off + close_pat.len();
283 }
284 out
285}
286
287fn is_tag_terminator(b: u8) -> bool {
288 b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'>' || b == b'/'
291}
292
293fn decode_entities(s: &str) -> String {
296 if !s.contains('&') {
297 return s.to_string();
298 }
299 let mut out = String::with_capacity(s.len());
300 let bytes = s.as_bytes();
301 let mut i = 0usize;
302 while i < bytes.len() {
303 if bytes[i] != b'&' {
304 let next_amp = s[i..].find('&').map(|p| i + p).unwrap_or(s.len());
310 out.push_str(&s[i..next_amp]);
311 i = next_amp;
312 continue;
313 }
314 let semi = match s[i..(i + 12).min(s.len())].find(';') {
316 Some(p) => i + p,
317 None => {
318 out.push('&');
319 i += 1;
320 continue;
321 }
322 };
323 let entity = &s[i + 1..semi];
324 let ch = match entity {
325 "amp" => Some('&'),
326 "lt" => Some('<'),
327 "gt" => Some('>'),
328 "quot" => Some('"'),
329 "apos" => Some('\''),
330 other if other.starts_with('#') => {
331 let body = &other[1..];
332 let cp =
333 if let Some(hex) = body.strip_prefix('x').or_else(|| body.strip_prefix('X')) {
334 u32::from_str_radix(hex, 16).ok()
335 } else {
336 body.parse::<u32>().ok()
337 };
338 cp.and_then(char::from_u32)
339 }
340 _ => None,
341 };
342 match ch {
343 Some(c) => {
344 out.push(c);
345 i = semi + 1;
346 }
347 None => {
348 out.push('&');
349 i += 1;
350 }
351 }
352 }
353 out
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 const TINY_XMP: &[u8] = br#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
361<x:xmpmeta xmlns:x="adobe:ns:meta/">
362 <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
363 <rdf:Description rdf:about=""
364 xmlns:dc="http://purl.org/dc/elements/1.1/"
365 xmlns:xmp="http://ns.adobe.com/xap/1.0/"
366 xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
367 <dc:title>
368 <rdf:Alt>
369 <rdf:li xml:lang="x-default">Tiny & Test</rdf:li>
370 </rdf:Alt>
371 </dc:title>
372 <dc:creator>
373 <rdf:Seq>
374 <rdf:li>Mark</rdf:li>
375 <rdf:li>Other</rdf:li>
376 </rdf:Seq>
377 </dc:creator>
378 <dc:subject>
379 <rdf:Bag>
380 <rdf:li>pdf</rdf:li>
381 <rdf:li>xmp</rdf:li>
382 <rdf:li>round-26</rdf:li>
383 </rdf:Bag>
384 </dc:subject>
385 <xmp:CreateDate>2026-05-10T12:00:00Z</xmp:CreateDate>
386 <xmp:CreatorTool>oxideav-pdf round 26</xmp:CreatorTool>
387 <pdf:Producer>oxideav-pdf 0.1.x</pdf:Producer>
388 <pdf:Keywords>foo,bar,baz</pdf:Keywords>
389 </rdf:Description>
390 </rdf:RDF>
391</x:xmpmeta>
392<?xpacket end="w"?>"#;
393
394 #[test]
395 fn parses_dublin_core_title_through_lang_alt() {
396 let p = XmpPacket::parse(TINY_XMP);
397 assert_eq!(p.dc_title.as_deref(), Some("Tiny & Test"));
398 }
399
400 #[test]
401 fn parses_dublin_core_creator_first_of_seq() {
402 let p = XmpPacket::parse(TINY_XMP);
403 assert_eq!(p.dc_creator.as_deref(), Some("Mark"));
404 }
405
406 #[test]
407 fn parses_dublin_core_subject_bag_in_order() {
408 let p = XmpPacket::parse(TINY_XMP);
409 assert_eq!(p.dc_subject, vec!["pdf", "xmp", "round-26"]);
410 }
411
412 #[test]
413 fn parses_xmp_basic_dates() {
414 let p = XmpPacket::parse(TINY_XMP);
415 assert_eq!(p.xmp_create_date.as_deref(), Some("2026-05-10T12:00:00Z"));
416 assert_eq!(p.xmp_creator_tool.as_deref(), Some("oxideav-pdf round 26"));
417 }
418
419 #[test]
420 fn parses_pdf_schema_producer_keywords() {
421 let p = XmpPacket::parse(TINY_XMP);
422 assert_eq!(p.pdf_producer.as_deref(), Some("oxideav-pdf 0.1.x"));
423 assert_eq!(p.pdf_keywords.as_deref(), Some("foo,bar,baz"));
424 }
425
426 const PDFA_2B_XMP: &[u8] = br#"<?xpacket begin=""?>
427<x:xmpmeta xmlns:x="adobe:ns:meta/">
428<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
429<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
430 <pdfaid:part>2</pdfaid:part>
431 <pdfaid:conformance>B</pdfaid:conformance>
432</rdf:Description>
433</rdf:RDF>
434</x:xmpmeta>
435<?xpacket end="w"?>"#;
436
437 #[test]
438 fn detects_pdf_a_2b_conformance() {
439 let p = XmpPacket::parse(PDFA_2B_XMP);
440 assert!(p.is_pdf_a());
441 assert_eq!(p.pdfaid_part, Some(2));
442 assert_eq!(p.pdfaid_conformance.as_deref(), Some("B"));
443 assert_eq!(p.pdf_a_conformance().as_deref(), Some("2B"));
444 }
445
446 const ATTR_FORM_XMP: &[u8] = br#"<?xpacket?>
447<x:xmpmeta xmlns:x="adobe:ns:meta/">
448<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
449<rdf:Description rdf:about=""
450 xmlns:pdf="http://ns.adobe.com/pdf/1.3/"
451 pdf:Producer="Inline Producer"
452 pdf:Keywords="x,y,z"/>
453</rdf:RDF>
454</x:xmpmeta>"#;
455
456 #[test]
457 fn parses_pdf_producer_in_attribute_form() {
458 let p = XmpPacket::parse(ATTR_FORM_XMP);
459 assert_eq!(p.pdf_producer.as_deref(), Some("Inline Producer"));
460 assert_eq!(p.pdf_keywords.as_deref(), Some("x,y,z"));
461 }
462
463 #[test]
464 fn empty_input_yields_default() {
465 let p = XmpPacket::parse(b"");
466 assert_eq!(p, XmpPacket::default());
467 assert!(!p.is_pdf_a());
468 }
469
470 #[test]
471 fn entity_decode_handles_amp_lt_gt_quot_apos_and_numeric() {
472 assert_eq!(decode_entities("a & b"), "a & b");
473 assert_eq!(decode_entities("<tag>"), "<tag>");
474 assert_eq!(
475 decode_entities("she said "hi""),
476 "she said \"hi\""
477 );
478 assert_eq!(decode_entities("it's"), "it's");
479 assert_eq!(decode_entities("A"), "A");
480 assert_eq!(decode_entities("中文"), "中文");
481 }
482
483 #[test]
484 fn extract_text_skips_close_only() {
485 let s = "<rdf:RDF></dc:title></rdf:RDF>";
487 assert_eq!(extract_text(s, "dc:title"), None);
488 }
489
490 #[test]
491 fn extract_text_handles_self_closing_form() {
492 let s = r#"<rdf:Description xmlns:dc="..." dc:format="application/pdf"/>"#;
493 assert_eq!(extract_text(s, "dc:format"), None);
496 assert_eq!(
497 extract_attr(s, "dc:format").as_deref(),
498 Some("application/pdf")
499 );
500 }
501
502 #[test]
503 fn extract_lang_alt_falls_back_to_plain_text() {
504 let s = "<dc:title>Plain Body</dc:title>";
506 assert_eq!(
507 extract_lang_alt(s, "dc:title").as_deref(),
508 Some("Plain Body")
509 );
510 }
511}