Skip to main content

oxideav_pdf/reader/
xmp.rs

1//! Round-26 — XMP packet field extraction (ISO 32000-1 §14.3.2 + Adobe
2//! XMP Specification, Sept-2012, ISO 16684-1).
3//!
4//! Round 19 surfaced the raw `/Metadata` packet bytes through
5//! [`crate::reader::DocumentReader::xmp_metadata`]; this module adds
6//! the small structured-field surface a "metadata" caller actually
7//! wants — the most common Dublin Core, XMP Basic, and PDF-schema
8//! entries, plus PDF/A conformance-level detection per ISO 19005-1
9//! §6.7 / 19005-2 §6.6 / 19005-3 §6.6.
10//!
11//! The parser is deliberately byte-string rather than full XML — XMP
12//! packets in the wild are mostly hand-crafted RDF/XML with predictable
13//! shapes, and pulling in a full XML parser dep just for namespace-
14//! qualified element scrapes is not worth the binary-size hit. The
15//! shapes we recognise:
16//!
17//! * **Element body** — `<ns:Tag>...</ns:Tag>` returns the inner text
18//!   verbatim (with the outer XML-entity decode applied: `&amp;` →
19//!   `&`, `&lt;` → `<`, `&gt;` → `>`, `&quot;` → `"`, `&apos;` → `'`).
20//! * **Attribute form** — `<rdf:Description ns:Tag="value" .../>`
21//!   returns the attribute value with the same entity decode.
22//! * **rdf:Alt / rdf:Bag / rdf:Seq language alternatives** —
23//!   `<dc:title><rdf:Alt><rdf:li xml:lang="x-default">…</rdf:li>…</rdf:Alt></dc:title>`
24//!   returns the first `rdf:li` body. The full alternative-language
25//!   table is out of scope; round-26 picks the default-language
26//!   sliver most consumers actually need.
27//!
28//! Unknown / missing fields surface as `None` — the parser never
29//! errors. Empty packet ⇒ default-constructed [`XmpPacket`].
30
31/// Structured view of an XMP packet's most useful fields.
32///
33/// All fields are best-effort scrapes — a malformed packet may
34/// produce partial data. Round-trip with a writer is not in scope:
35/// XMP is generally written by external tools (Adobe XMP SDK,
36/// `exiftool`, …) and read by the consumer.
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct XmpPacket {
39    // ── Dublin Core (`dc:`, http://purl.org/dc/elements/1.1/) ────────
40    /// `dc:title` — usually wrapped in `rdf:Alt` for language
41    /// alternatives; we surface the default-language sliver.
42    pub dc_title: Option<String>,
43    /// `dc:creator` — author(s); we collapse the `rdf:Seq` to the
44    /// first entry (the common case is exactly one creator).
45    pub dc_creator: Option<String>,
46    /// `dc:description` — same shape as `dc:title`.
47    pub dc_description: Option<String>,
48    /// `dc:subject` — `rdf:Bag` of keywords; we surface the full
49    /// list in document order.
50    pub dc_subject: Vec<String>,
51    /// `dc:rights` — copyright statement; same shape as `dc:title`.
52    pub dc_rights: Option<String>,
53    /// `dc:format` — usually `application/pdf` for PDF documents.
54    pub dc_format: Option<String>,
55
56    // ── XMP Basic (`xmp:`, http://ns.adobe.com/xap/1.0/) ─────────────
57    /// `xmp:CreateDate` — ISO 8601 date-time at first creation.
58    pub xmp_create_date: Option<String>,
59    /// `xmp:ModifyDate` — ISO 8601 date-time at last modification.
60    pub xmp_modify_date: Option<String>,
61    /// `xmp:MetadataDate` — ISO 8601 date-time the XMP packet itself
62    /// was last touched.
63    pub xmp_metadata_date: Option<String>,
64    /// `xmp:CreatorTool` — application that authored the document
65    /// (e.g. `Adobe InDesign 16.0`).
66    pub xmp_creator_tool: Option<String>,
67
68    // ── PDF schema (`pdf:`, http://ns.adobe.com/pdf/1.3/) ────────────
69    /// `pdf:Producer` — application that wrote the PDF (often the
70    /// same as `xmp:CreatorTool` but distinct in pipelines that
71    /// separate authoring from rendering).
72    pub pdf_producer: Option<String>,
73    /// `pdf:Keywords` — same comma-separated list a PDF `/Info`
74    /// dictionary's `/Keywords` would carry.
75    pub pdf_keywords: Option<String>,
76    /// `pdf:PDFVersion` — version of the PDF spec the file targets.
77    pub pdf_version: Option<String>,
78    /// `pdf:Trapped` — `True` / `False` / `Unknown` per Adobe's
79    /// trapping convention.
80    pub pdf_trapped: Option<String>,
81
82    // ── PDF/A identification schema (`pdfaid:`,
83    //     http://www.aiim.org/pdfa/ns/id/) ─────────────────────────
84    /// `pdfaid:part` — PDF/A part (1, 2, 3, 4) per ISO 19005-x.
85    pub pdfaid_part: Option<u8>,
86    /// `pdfaid:conformance` — conformance level (`A`, `B`, `U`, `E`,
87    /// `F`) per ISO 19005-x §6.x.
88    pub pdfaid_conformance: Option<String>,
89}
90
91impl XmpPacket {
92    /// Parse an XMP packet from the raw bytes returned by
93    /// [`crate::reader::DocumentReader::xmp_metadata`]. Best-effort —
94    /// missing fields surface as `None`, never errors.
95    pub fn parse(bytes: &[u8]) -> Self {
96        // Allow lossy UTF-8 — most XMP packets are ASCII-only or UTF-8;
97        // a stray non-UTF-8 byte gets replaced with U+FFFD rather than
98        // killing the whole parse.
99        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    /// True when the packet declares a PDF/A identification (per
145    /// ISO 19005-x §6.x) — at minimum `pdfaid:part` is set.
146    pub fn is_pdf_a(&self) -> bool {
147        self.pdfaid_part.is_some()
148    }
149
150    /// PDF/A conformance designator like `1B` or `2A` — concatenation
151    /// of part + conformance — when both are declared.
152    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
161/// Find the inner text of `<ns:Tag>...</ns:Tag>`.
162///
163/// Skips packets without a matching open / close pair, returns the
164/// trimmed inner text otherwise. The inner text is XML-entity-decoded.
165fn extract_text(haystack: &str, tag: &str) -> Option<String> {
166    // Build `<tag` and `</tag>` patterns. We accept `<tag>` (no
167    // attributes) and `<tag attr="...">…` (with attributes).
168    let open_pat = format!("<{}", tag);
169    let close_pat = format!("</{}>", tag);
170    let start_idx = haystack.find(&open_pat)?;
171    // Walk past the open tag — find the next `>`.
172    let after_open_name = start_idx + open_pat.len();
173    // Reject `<tag-suffix>` matches (e.g. `dc:titleX`).
174    let next_byte = haystack.as_bytes().get(after_open_name)?;
175    if !is_tag_terminator(*next_byte) {
176        // Try to find a later occurrence — keep searching.
177        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    // Self-closing form: `<tag .../>` — no inner text.
186    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
195/// Find an attribute value for `tag="value"` anywhere in the string.
196///
197/// Useful for the `<rdf:Description ns:Tag="value" .../>` shape that
198/// XMP often uses for short fields.
199fn 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        // Make sure the byte before is a valid attr-separator (whitespace
205        // or tag-open) — otherwise we matched a longer attribute name.
206        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
220/// Find the first `<rdf:li>...</rdf:li>` inside `<ns:Tag><rdf:Alt>...`.
221///
222/// XMP's language-alternative pattern: dc:title etc. wrap their
223/// localised values in `rdf:Alt`. The default-language entry is
224/// usually first; we return its body. Falls back to plain
225/// `extract_text` for tags that don't use the rdf:Alt wrapper.
226fn 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
231/// Find the full `<rdf:Bag>` of `<rdf:li>...</rdf:li>` entries inside
232/// `<ns:Tag>`.
233fn 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
240/// Find the first `<rdf:li>...</rdf:li>` inside an `rdf:Seq` wrapper.
241/// rdf:Seq is ordered; the first entry is the most-preferred value.
242fn 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        // Self-closing — skip and advance.
272        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    // After the tag *name*, any of: whitespace, attribute-sep `>`, or
289    // self-close `/`. (`>` and `/` both signal end-of-name.)
290    b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'>' || b == b'/'
291}
292
293/// Decode the standard five XML entities. Numeric character references
294/// (`&#NNN;` / `&#xHEX;`) are also decoded for the BMP range.
295fn 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            // Push the byte; only ASCII reaches this branch when the
305            // string is ASCII-only — for non-ASCII we restart at the
306            // next `&` via the broader chunked path below.
307            // To keep multi-byte UTF-8 sequences intact we use char
308            // boundaries.
309            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        // Look for `;` within a small window.
315        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 &amp; 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 &amp; b"), "a & b");
473        assert_eq!(decode_entities("&lt;tag&gt;"), "<tag>");
474        assert_eq!(
475            decode_entities("she said &quot;hi&quot;"),
476            "she said \"hi\""
477        );
478        assert_eq!(decode_entities("it&apos;s"), "it's");
479        assert_eq!(decode_entities("&#65;"), "A");
480        assert_eq!(decode_entities("&#x4E2D;&#x6587;"), "中文");
481    }
482
483    #[test]
484    fn extract_text_skips_close_only() {
485        // `</dc:title>` without an opening tag must not match.
486        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        // Self-closing — no inner text. Falls back to attribute form
494        // at the call site.
495        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        // No rdf:Alt wrapper — return the trimmed body.
505        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}