#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct XmpPacket {
pub dc_title: Option<String>,
pub dc_creator: Option<String>,
pub dc_description: Option<String>,
pub dc_subject: Vec<String>,
pub dc_rights: Option<String>,
pub dc_format: Option<String>,
pub xmp_create_date: Option<String>,
pub xmp_modify_date: Option<String>,
pub xmp_metadata_date: Option<String>,
pub xmp_creator_tool: Option<String>,
pub pdf_producer: Option<String>,
pub pdf_keywords: Option<String>,
pub pdf_version: Option<String>,
pub pdf_trapped: Option<String>,
pub pdfaid_part: Option<u8>,
pub pdfaid_conformance: Option<String>,
}
impl XmpPacket {
pub fn parse(bytes: &[u8]) -> Self {
let owned;
let s: &str = match std::str::from_utf8(bytes) {
Ok(s) => s,
Err(_) => {
owned = String::from_utf8_lossy(bytes).into_owned();
owned.as_str()
}
};
Self {
dc_title: extract_lang_alt(s, "dc:title").or_else(|| extract_attr(s, "dc:title")),
dc_creator: extract_seq_first(s, "dc:creator")
.or_else(|| extract_attr(s, "dc:creator")),
dc_description: extract_lang_alt(s, "dc:description")
.or_else(|| extract_attr(s, "dc:description")),
dc_subject: extract_bag(s, "dc:subject"),
dc_rights: extract_lang_alt(s, "dc:rights").or_else(|| extract_attr(s, "dc:rights")),
dc_format: extract_text(s, "dc:format").or_else(|| extract_attr(s, "dc:format")),
xmp_create_date: extract_text(s, "xmp:CreateDate")
.or_else(|| extract_attr(s, "xmp:CreateDate")),
xmp_modify_date: extract_text(s, "xmp:ModifyDate")
.or_else(|| extract_attr(s, "xmp:ModifyDate")),
xmp_metadata_date: extract_text(s, "xmp:MetadataDate")
.or_else(|| extract_attr(s, "xmp:MetadataDate")),
xmp_creator_tool: extract_text(s, "xmp:CreatorTool")
.or_else(|| extract_attr(s, "xmp:CreatorTool")),
pdf_producer: extract_text(s, "pdf:Producer")
.or_else(|| extract_attr(s, "pdf:Producer")),
pdf_keywords: extract_text(s, "pdf:Keywords")
.or_else(|| extract_attr(s, "pdf:Keywords")),
pdf_version: extract_text(s, "pdf:PDFVersion")
.or_else(|| extract_attr(s, "pdf:PDFVersion")),
pdf_trapped: extract_text(s, "pdf:Trapped").or_else(|| extract_attr(s, "pdf:Trapped")),
pdfaid_part: extract_text(s, "pdfaid:part")
.or_else(|| extract_attr(s, "pdfaid:part"))
.and_then(|v| v.trim().parse::<u8>().ok()),
pdfaid_conformance: extract_text(s, "pdfaid:conformance")
.or_else(|| extract_attr(s, "pdfaid:conformance"))
.map(|s| s.trim().to_string()),
}
}
pub fn is_pdf_a(&self) -> bool {
self.pdfaid_part.is_some()
}
pub fn pdf_a_conformance(&self) -> Option<String> {
Some(format!(
"{}{}",
self.pdfaid_part?,
self.pdfaid_conformance.as_deref()?
))
}
}
fn extract_text(haystack: &str, tag: &str) -> Option<String> {
let open_pat = format!("<{}", tag);
let close_pat = format!("</{}>", tag);
let start_idx = haystack.find(&open_pat)?;
let after_open_name = start_idx + open_pat.len();
let next_byte = haystack.as_bytes().get(after_open_name)?;
if !is_tag_terminator(*next_byte) {
let rest_off = after_open_name;
let rest = &haystack[rest_off..];
if let Some(skip) = rest.find(&open_pat) {
return extract_text(&haystack[rest_off + skip..], tag);
}
return None;
}
let close_brace_off = haystack[after_open_name..].find('>')? + after_open_name;
if close_brace_off > 0 && haystack.as_bytes()[close_brace_off - 1] == b'/' {
return None;
}
let body_start = close_brace_off + 1;
let close_off = haystack[body_start..].find(&close_pat)? + body_start;
let body = &haystack[body_start..close_off];
Some(decode_entities(body.trim()))
}
fn extract_attr(haystack: &str, attr_name: &str) -> Option<String> {
let pat = format!("{}=\"", attr_name);
let mut search_from = 0usize;
while let Some(rel) = haystack[search_from..].find(&pat) {
let off = search_from + rel;
if off > 0 {
let prev = haystack.as_bytes()[off - 1];
if !prev.is_ascii_whitespace() && prev != b'<' {
search_from = off + pat.len();
continue;
}
}
let value_start = off + pat.len();
let value_end = haystack[value_start..].find('"')? + value_start;
return Some(decode_entities(&haystack[value_start..value_end]));
}
None
}
fn extract_lang_alt(haystack: &str, tag: &str) -> Option<String> {
let inner = extract_text(haystack, tag)?;
extract_first_li(&inner).or(Some(inner))
}
fn extract_bag(haystack: &str, tag: &str) -> Vec<String> {
let Some(inner) = extract_text(haystack, tag) else {
return Vec::new();
};
extract_all_li(&inner)
}
fn extract_seq_first(haystack: &str, tag: &str) -> Option<String> {
let inner = extract_text(haystack, tag)?;
extract_first_li(&inner).or(Some(inner))
}
fn extract_first_li(haystack: &str) -> Option<String> {
extract_text(haystack, "rdf:li")
}
fn extract_all_li(haystack: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cursor = 0usize;
let open_pat = "<rdf:li";
let close_pat = "</rdf:li>";
while let Some(rel) = haystack[cursor..].find(open_pat) {
let open_off = cursor + rel;
let after_name = open_off + open_pat.len();
let next_byte = match haystack.as_bytes().get(after_name) {
Some(b) => *b,
None => break,
};
if !is_tag_terminator(next_byte) {
cursor = after_name;
continue;
}
let Some(close_brace_rel) = haystack[after_name..].find('>') else {
break;
};
let close_brace = after_name + close_brace_rel;
if close_brace > 0 && haystack.as_bytes()[close_brace - 1] == b'/' {
cursor = close_brace + 1;
continue;
}
let body_start = close_brace + 1;
let Some(close_rel) = haystack[body_start..].find(close_pat) else {
break;
};
let close_off = body_start + close_rel;
out.push(decode_entities(haystack[body_start..close_off].trim()));
cursor = close_off + close_pat.len();
}
out
}
fn is_tag_terminator(b: u8) -> bool {
b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == b'>' || b == b'/'
}
fn decode_entities(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
if bytes[i] != b'&' {
let next_amp = s[i..].find('&').map(|p| i + p).unwrap_or(s.len());
out.push_str(&s[i..next_amp]);
i = next_amp;
continue;
}
let semi = match s[i..(i + 12).min(s.len())].find(';') {
Some(p) => i + p,
None => {
out.push('&');
i += 1;
continue;
}
};
let entity = &s[i + 1..semi];
let ch = match entity {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
other if other.starts_with('#') => {
let body = &other[1..];
let cp =
if let Some(hex) = body.strip_prefix('x').or_else(|| body.strip_prefix('X')) {
u32::from_str_radix(hex, 16).ok()
} else {
body.parse::<u32>().ok()
};
cp.and_then(char::from_u32)
}
_ => None,
};
match ch {
Some(c) => {
out.push(c);
i = semi + 1;
}
None => {
out.push('&');
i += 1;
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const TINY_XMP: &[u8] = br#"<?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/">
<dc:title>
<rdf:Alt>
<rdf:li xml:lang="x-default">Tiny & Test</rdf:li>
</rdf:Alt>
</dc:title>
<dc:creator>
<rdf:Seq>
<rdf:li>Mark</rdf:li>
<rdf:li>Other</rdf:li>
</rdf:Seq>
</dc:creator>
<dc:subject>
<rdf:Bag>
<rdf:li>pdf</rdf:li>
<rdf:li>xmp</rdf:li>
<rdf:li>round-26</rdf:li>
</rdf:Bag>
</dc:subject>
<xmp:CreateDate>2026-05-10T12:00:00Z</xmp:CreateDate>
<xmp:CreatorTool>oxideav-pdf round 26</xmp:CreatorTool>
<pdf:Producer>oxideav-pdf 0.1.x</pdf:Producer>
<pdf:Keywords>foo,bar,baz</pdf:Keywords>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
#[test]
fn parses_dublin_core_title_through_lang_alt() {
let p = XmpPacket::parse(TINY_XMP);
assert_eq!(p.dc_title.as_deref(), Some("Tiny & Test"));
}
#[test]
fn parses_dublin_core_creator_first_of_seq() {
let p = XmpPacket::parse(TINY_XMP);
assert_eq!(p.dc_creator.as_deref(), Some("Mark"));
}
#[test]
fn parses_dublin_core_subject_bag_in_order() {
let p = XmpPacket::parse(TINY_XMP);
assert_eq!(p.dc_subject, vec!["pdf", "xmp", "round-26"]);
}
#[test]
fn parses_xmp_basic_dates() {
let p = XmpPacket::parse(TINY_XMP);
assert_eq!(p.xmp_create_date.as_deref(), Some("2026-05-10T12:00:00Z"));
assert_eq!(p.xmp_creator_tool.as_deref(), Some("oxideav-pdf round 26"));
}
#[test]
fn parses_pdf_schema_producer_keywords() {
let p = XmpPacket::parse(TINY_XMP);
assert_eq!(p.pdf_producer.as_deref(), Some("oxideav-pdf 0.1.x"));
assert_eq!(p.pdf_keywords.as_deref(), Some("foo,bar,baz"));
}
const PDFA_2B_XMP: &[u8] = br#"<?xpacket begin=""?>
<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:pdfaid="http://www.aiim.org/pdfa/ns/id/">
<pdfaid:part>2</pdfaid:part>
<pdfaid:conformance>B</pdfaid:conformance>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
#[test]
fn detects_pdf_a_2b_conformance() {
let p = XmpPacket::parse(PDFA_2B_XMP);
assert!(p.is_pdf_a());
assert_eq!(p.pdfaid_part, Some(2));
assert_eq!(p.pdfaid_conformance.as_deref(), Some("B"));
assert_eq!(p.pdf_a_conformance().as_deref(), Some("2B"));
}
const ATTR_FORM_XMP: &[u8] = br#"<?xpacket?>
<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:Producer="Inline Producer"
pdf:Keywords="x,y,z"/>
</rdf:RDF>
</x:xmpmeta>"#;
#[test]
fn parses_pdf_producer_in_attribute_form() {
let p = XmpPacket::parse(ATTR_FORM_XMP);
assert_eq!(p.pdf_producer.as_deref(), Some("Inline Producer"));
assert_eq!(p.pdf_keywords.as_deref(), Some("x,y,z"));
}
#[test]
fn empty_input_yields_default() {
let p = XmpPacket::parse(b"");
assert_eq!(p, XmpPacket::default());
assert!(!p.is_pdf_a());
}
#[test]
fn entity_decode_handles_amp_lt_gt_quot_apos_and_numeric() {
assert_eq!(decode_entities("a & b"), "a & b");
assert_eq!(decode_entities("<tag>"), "<tag>");
assert_eq!(
decode_entities("she said "hi""),
"she said \"hi\""
);
assert_eq!(decode_entities("it's"), "it's");
assert_eq!(decode_entities("A"), "A");
assert_eq!(decode_entities("中文"), "中文");
}
#[test]
fn extract_text_skips_close_only() {
let s = "<rdf:RDF></dc:title></rdf:RDF>";
assert_eq!(extract_text(s, "dc:title"), None);
}
#[test]
fn extract_text_handles_self_closing_form() {
let s = r#"<rdf:Description xmlns:dc="..." dc:format="application/pdf"/>"#;
assert_eq!(extract_text(s, "dc:format"), None);
assert_eq!(
extract_attr(s, "dc:format").as_deref(),
Some("application/pdf")
);
}
#[test]
fn extract_lang_alt_falls_back_to_plain_text() {
let s = "<dc:title>Plain Body</dc:title>";
assert_eq!(
extract_lang_alt(s, "dc:title").as_deref(),
Some("Plain Body")
);
}
}