//! XMP metadata — the catalogue `/Metadata` packet. //! //! A document's `/Info` dictionary (see [`crate::document::DocumentInfo`]) //! holds the classic keys, but the modern home for the same facts is an XMP //! packet: a small RDF/XML document stored as the catalogue's `/Metadata` //! stream. Readers that trust `/Info` alone miss what a PDF/A or PDF/UA //! workflow put into XMP, and the two can even disagree. //! //! This module is deliberately small and dependency-free: //! //! - [`read_xmp`] decodes the `/Metadata` stream and maps the Dublin Core //! (`dc:`) and Adobe (`pdf:`, `xmp:`) fields onto [`XmpMetadata`]. //! - [`XmpMetadata::to_document_info`] bridges to the document's existing //! [`crate::document::DocumentInfo`]. //! - [`XmpMetadata::to_packet`] emits a canonical XMP packet so a writer can //! publish the same facts into a fresh `/Metadata` stream (see //! `nigig_pdf_cos::writer::Builder::set_xmp_metadata`). //! //! The parser is **not** a general XML parser. It scavenges the handful of //! elements and attributes an XMP metadata packet actually uses (title, //! creator, description, keywords, producer, creator-tool) and ignores the //! rest. It is tolerant — a packet it does not understand yields `None` //! fields rather than failing — and it does not validate the RDF. use crate::document::DocumentInfo; use crate::PdfDocument; use nigig_pdf_cos::PdfResult; /// The metadata a viewer shows or a workflow stamps: Dublin Core plus the /// Adobe PDF/A and XMP convenience fields. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct XmpMetadata { pub title: Option, /// The authors, in list order. pub creators: Vec, pub description: Option, /// Subject keywords, one per `rdf:li`. pub keywords: Vec, /// The application that created the original content. pub creator_tool: Option, /// `pdf:Producer` — what produced the final PDF. pub producer: Option, pub creation_date: Option, pub mod_date: Option, } impl XmpMetadata { /// Merge these fields onto a [`DocumentInfo`], the richer dictionary /// winning only where it has a value. This is the direction a reader /// wants: XMP is authoritative for fields `/Info` does not carry. pub fn to_document_info(&self) -> DocumentInfo { DocumentInfo { title: self.title.clone(), author: first(self.creators.as_slice()), subject: self.description.clone(), keywords: nonempty(self.keywords.as_slice()), producer: self.producer.clone(), creator: self.creator_tool.clone(), creation_date: self.creation_date.clone(), mod_date: self.mod_date.clone(), } } /// Serialize to a well-formed XMP packet (with the `xpacket` /// processing-instruction wrapper, as a `/Subtype /XML` `/Metadata` /// stream is expected to carry). pub fn to_packet(&self) -> Vec { let mut out = String::from( "\n\ \n\ \n", ); out.push_str(" \n"); if let Some(t) = &self.title { out.push_str(&format!(" {}\n", xml_escape(t))); } if !self.creators.is_empty() { out.push_str(" "); for c in &self.creators { out.push_str(&format!("{}", xml_escape(c))); } out.push_str("\n"); } if let Some(d) = &self.description { out.push_str(&format!(" {}\n", xml_escape(d))); } if !self.keywords.is_empty() { out.push_str(" "); for k in &self.keywords { out.push_str(&format!("{}", xml_escape(k))); } out.push_str("\n"); } if let Some(c) = &self.creator_tool { out.push_str(&format!(" {}\n", xml_escape(c))); } if let Some(p) = &self.producer { out.push_str(&format!(" {}\n", xml_escape(p))); } if let Some(d) = &self.creation_date { out.push_str(&format!(" {}\n", xml_escape(d))); } if let Some(d) = &self.mod_date { out.push_str(&format!(" {}\n", xml_escape(d))); } out.push_str(" \n"); out.push_str("\n\n\n"); out.into_bytes() } } fn first(v: &[String]) -> Option { v.first().cloned() } fn nonempty(v: &[String]) -> Option { if v.is_empty() { None } else { Some(v.join(", ")) } } /// Read the catalogue `/Metadata` packet and parse its XMP fields. /// /// Returns `Ok(None)` when the catalogue has no `/Metadata` stream, and an /// `XmpMetadata` (possibly with all-`None` fields) when it has one it cannot /// fully parse. This is deliberately non-fatal: a missing or foreign packet /// should not make opening a document an error. pub fn read_xmp(doc: &mut PdfDocument) -> PdfResult> { let Some(root_ref) = doc.trailer().get_ref("Root") else { return Ok(None); }; let catalog = doc.resolve_ref(root_ref)?; let Some(catalog) = catalog.as_dict() else { return Ok(None); }; let metadata = match catalog.get("Metadata") { Some(m) => m.clone(), None => return Ok(None), }; let bytes = match doc.resolve_stream(&metadata) { Ok(b) => b, Err(_) => return Ok(None), }; Ok(Some(parse_packet(&bytes))) } /// Parse an XMP packet, scavenging the metadata-relevant fields. /// /// The quirks of the packet matter: /// /// - A `dc:title` is usually an `rdf:Alt` container whose literals carry an /// `xml:lang`; we take the `x-default` (or first) `rdf:li`. /// - `dc:creator` is an ordered `rdf:Seq`. /// - `dc:subject` is a `rdf:Bag`. /// - `pdf:Producer` and `xmp:CreatorTool` are plain elements, not containers. pub fn parse_packet(bytes: &[u8]) -> XmpMetadata { let src = String::from_utf8_lossy(bytes); XmpMetadata { title: lang_alt(&src, "dc:title"), creators: seq_literals(&src, "dc:creator"), description: lang_alt(&src, "dc:description"), keywords: bag_literals(&src, "dc:subject"), creator_tool: element_text(&src, "xmp:CreatorTool"), producer: element_text(&src, "pdf:Producer"), creation_date: element_text(&src, "xmp:CreateDate"), mod_date: element_text(&src, "xmp:ModifyDate"), } } // ---------------------------------------------------------------- helpers /// Keep a whole element (opening tag through closing tag) for an element /// whose name may be `dc:title` or `title` (XMP writers vary). fn element_span<'a>(src: &'a str, name: &str) -> Option<&'a str> { let shorthand = name.rsplit(':').next().unwrap_or(name); for (tag_open, tag_low) in candidates(src, name, shorthand) { if let Some(close) = close_tag(src, &tag_low, tag_open) { return Some(&src[tag_open..close]); } } None } /// All opening-tag positions (with their lowercased name) for an element, /// in document order. We may encounter `` and ``; both name /// the same element and both are accepted. fn candidates<'a>(src: &'a str, name: &str, shorthand: &str) -> Vec<(usize, String)> { let mut out = Vec::new(); let lower = src.to_ascii_lowercase(); let name_low = name.to_ascii_lowercase(); let short_low = shorthand.to_ascii_lowercase(); let mut search = 0usize; while let Some(rel) = lower[search..].find('<') { let start = search + rel; // Skip comments and processing instructions. if lower[start..].starts_with("<!--") || lower[start..].starts_with("<?") { let closer = lower[start..].find(">").map(|i| start + i + 1).unwrap_or(src.len()); search = closer; continue; } let after = start + 1; if lower[after..].starts_with('/') { let closer = lower[after..].find(">").map(|i| after + i + 1).unwrap_or(src.len()); search = closer; continue; } // Read the tag name. let name_end = lower[after..] .find(|c: char| c == ' ' || c == '>' || c == '\n' || c == '\t' || c == '\r') .map(|i| after + i) .unwrap_or(src.len()); let tag = &lower[after..name_end]; let is_match = tag == name_low || tag == short_low; if is_match { out.push((start, tag.to_string())); } let closer = lower[after..].find(">").map(|i| after + i + 1).unwrap_or(src.len()); search = closer; } out } /// Find the matching closing tag for an opening tag at `open`, searching /// only after the opening tag. fn close_tag(src: &str, name: &str, open: usize) -> Option<usize> { let lower = src.to_ascii_lowercase(); let target = format!("</{}", name); let from = &lower[open..]; let pos = from.find(&target)?; let end = from[pos..].find('>').map(|i| pos + i + 1)?; Some(open + end) } fn xml_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } fn unescape(s: &str) -> String { s.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace(""", "\"") .replace("'", "'") } fn element_text(src: &str, name: &str) -> Option<String> { let span = element_span(src, name)?; // Text content: everything that is not inside a nested tag. let lower = span.to_ascii_lowercase(); let mut out = String::new(); let mut rest = span; let mut search = 0usize; while let Some(start) = lower[search..].find('<') { let s = search + start; let close = lower[s..].find('>').map(|i| s + i + 1).unwrap_or(rest.len()); out.push_str(&rest[search..search + start]); search = close; } out.push_str(&rest[search..]); let t = out.trim(); if t.is_empty() { None } else { Some(unescape(t)) } } /// The `x-default` (or first) literal of an `rdf:Alt`-style container. fn lang_alt(src: &str, name: &str) -> Option<String> { let span = element_span(src, name)?; let mut best: Option<String> = None; let literals = li_literals(span); for lit in literals { let xml_lang = lit.0; if xml_lang.as_deref() == Some("x-default") { return lit.1; } best = best.or(lit.1); } best } /// `rdf:li` literal contents within a container, with their `xml:lang`. fn li_literals(span: &str) -> Vec<(Option<String>, Option<String>)> { let mut out = Vec::new(); let lower = span.to_ascii_lowercase(); let mut search = 0usize; while let Some(pos) = lower[search..].find("<rdf:li") { let open = search + pos; // Skip a closing tag `</rdf:li` — it re-matches the same literal. if lower[open..].starts_with("</rdf:li") { let after = lower[open..].find('>').map(|i| open + i + 1).unwrap_or(span.len()); search = after; continue; } // The opening tag runs to the next `>`. let Some(end_rel) = lower[open..].find('>') else { break }; let tag_end = open + end_rel; let open_tag = &lower[open..tag_end]; let self_closing = open_tag.ends_with('/'); let xml_lang = attr_value(&span[open..tag_end], "xml:lang"); if self_closing { out.push((xml_lang, None)); search = tag_end + 1; continue; } let content = &span[tag_end + 1..]; let content_lower = &lower[tag_end + 1..]; match content_lower.find("</rdf:li") { Some(close_rel) => { let text = content[..close_rel].trim(); out.push(( xml_lang, Some(unescape(text)).filter(|t| !t.is_empty()), )); let close = tag_end + 1 + close_rel; let end = content_lower[close_rel..] .find('>') .map(|i| close + i + 1) .unwrap_or(span.len()); search = end; } None => break, } } out } fn seq_literals(src: &str, name: &str) -> Vec<String> { let Some(span) = element_span(src, name) else { return Vec::new(); }; li_literals(span).into_iter().filter_map(|(_, t)| t).collect() } fn bag_literals(src: &str, name: &str) -> Vec<String> { let Some(span) = element_span(src, name) else { return Vec::new(); }; li_literals(span).into_iter().filter_map(|(_, t)| t).collect() } fn attr_value(tag: &str, name: &str) -> Option<String> { let lower = tag.to_ascii_lowercase(); let needle = format!("{}=", name.to_ascii_lowercase()); let pos = lower.find(&needle)?; let rest = &tag[pos + needle.len()..]; let open = rest.trim_start().chars().next()?; let quote = if open == '"' || open == '\'' { Some(open) } else { None }; match quote { Some(q) => { let start = tag[pos + needle.len()..] .find(q) .map(|i| pos + needle.len() + i + 1)?; let end_rel = tag[start..].find(q)?; Some(unescape(&tag[start..start + end_rel])) } None => { let start = pos + needle.len(); let end = tag[start..] .find(|c: char| c == ' ' || c == '>') .unwrap_or(tag.len() - start); Some(tag[start..start + end].trim().to_string()) } } } #[cfg(test)] mod tests { use super::*; fn packet() -> Vec<u8> { XmpMetadata { title: Some("A Test & Specimen".into()), creators: vec!["Ada Lovelace".into(), "Byron".into()], description: Some("Describes the thing".into()), keywords: vec!["sample".into(), "specimen".into()], creator_tool: Some("Nigig Builder".into()), producer: Some("Nigig".into()), creation_date: Some("2026-09-04T12:00:00Z".into()), mod_date: Some("2026-09-05T12:00:00Z".into()), } .to_packet() } #[test] fn packet_round_trips_through_the_parser() { let parsed = parse_packet(&packet()); assert_eq!(parsed.title.as_deref(), Some("A Test & Specimen")); assert_eq!(parsed.creators, vec!["Ada Lovelace", "Byron"]); assert_eq!(parsed.description.as_deref(), Some("Describes the thing")); assert_eq!(parsed.keywords, vec!["sample", "specimen"]); assert_eq!(parsed.creator_tool.as_deref(), Some("Nigig Builder")); assert_eq!(parsed.producer.as_deref(), Some("Nigig")); assert_eq!(parsed.creation_date.as_deref(), Some("2026-09-04T12:00:00Z")); assert_eq!(parsed.mod_date.as_deref(), Some("2026-09-05T12:00:00Z")); } #[test] fn empty_packet_parses_to_empty_fields() { let p = parse_packet(b"<rdf:RDF></rdf:RDF>"); assert_eq!(p, XmpMetadata::default()); } #[test] fn garbage_is_tolerated_not_fatal() { let p = parse_packet(b"this is not xml at all"); assert_eq!(p, XmpMetadata::default()); } #[test] fn x_default_literal_wins_over_others() { let xml = "\ <dc:title><rdf:Alt>\ <rdf:li xml:lang=\"fr\">Faux</rdf:li>\ <rdf:li xml:lang=\"x-default\">Right</rdf:li>\ </rdf:Alt></dc:title>"; assert_eq!(parse_packet(xml.as_bytes()).title.as_deref(), Some("Right")); } #[test] fn plain_element_without_container_parses() { let xml = "<x:xmpmeta><rdf:RDF><rdf:Description><pdf:Producer>Acme</pdf:Producer></rdf:Description></rdf:RDF></x:xmpmeta>"; let p = parse_packet(xml.as_bytes()); assert_eq!(p.producer.as_deref(), Some("Acme")); assert_eq!(p.creator_tool, None); } #[test] fn document_info_bridge_uses_first_creator() { let m = XmpMetadata { creators: vec!["A".into(), "B".into()], keywords: vec!["k1".into(), "k2".into()], description: Some("d".into()), ..Default::default() }; let info = m.to_document_info(); assert_eq!(info.author.as_deref(), Some("A")); assert_eq!(info.keywords.as_deref(), Some("k1, k2")); assert_eq!(info.subject.as_deref(), Some("d")); } }