Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
- pdf-graphics: new cff, truetype, cjk_cmap, disk_cache, document_ai, path, reflow, text_diff modules + port tests (all green); syntax/type fixes for the aarch64 build (stray brace, &u8 derefs, f2dot14 assign). - pdf-document: ocr, pdf_a, xmp modules + roundtrip tests; annotation editor updates. - pdf-makepad: handle RenderCommand::SetOverprint (report via TransparencyError per ADR 0009, no silent drop); renderer/page_view updates for the new recording ops. - matrix_client: nimanyatta native module (ungated; the aarch64 wrapper needs it unconditionally). - pageflipnav home_screen: project_store sync is host-only until cad-ui's Widget Script derive is fixed for aarch64. Verified: cargo check on all four crates clean at fork 66cc4f15f; nigig-pdf-graphics tests pass; pageflipnav aarch64 release links.
450 lines
17 KiB
Rust
450 lines
17 KiB
Rust
//! 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<String>,
|
|
/// The authors, in list order.
|
|
pub creators: Vec<String>,
|
|
pub description: Option<String>,
|
|
/// Subject keywords, one per `rdf:li`.
|
|
pub keywords: Vec<String>,
|
|
/// The application that created the original content.
|
|
pub creator_tool: Option<String>,
|
|
/// `pdf:Producer` — what produced the final PDF.
|
|
pub producer: Option<String>,
|
|
pub creation_date: Option<String>,
|
|
pub mod_date: Option<String>,
|
|
}
|
|
|
|
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<u8> {
|
|
let mut out = String::from(
|
|
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
|
|
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" x:xmptk=\"nigig\">\n\
|
|
<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n",
|
|
);
|
|
|
|
out.push_str(" <rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\">\n");
|
|
|
|
if let Some(t) = &self.title {
|
|
out.push_str(&format!(" <dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></dc:title>\n", xml_escape(t)));
|
|
}
|
|
if !self.creators.is_empty() {
|
|
out.push_str(" <dc:creator><rdf:Seq>");
|
|
for c in &self.creators {
|
|
out.push_str(&format!("<rdf:li>{}</rdf:li>", xml_escape(c)));
|
|
}
|
|
out.push_str("</rdf:Seq></dc:creator>\n");
|
|
}
|
|
if let Some(d) = &self.description {
|
|
out.push_str(&format!(" <dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></dc:description>\n", xml_escape(d)));
|
|
}
|
|
if !self.keywords.is_empty() {
|
|
out.push_str(" <dc:subject><rdf:Bag>");
|
|
for k in &self.keywords {
|
|
out.push_str(&format!("<rdf:li>{}</rdf:li>", xml_escape(k)));
|
|
}
|
|
out.push_str("</rdf:Bag></dc:subject>\n");
|
|
}
|
|
if let Some(c) = &self.creator_tool {
|
|
out.push_str(&format!(" <xmp:CreatorTool>{}</xmp:CreatorTool>\n", xml_escape(c)));
|
|
}
|
|
if let Some(p) = &self.producer {
|
|
out.push_str(&format!(" <pdf:Producer>{}</pdf:Producer>\n", xml_escape(p)));
|
|
}
|
|
if let Some(d) = &self.creation_date {
|
|
out.push_str(&format!(" <xmp:CreateDate>{}</xmp:CreateDate>\n", xml_escape(d)));
|
|
}
|
|
if let Some(d) = &self.mod_date {
|
|
out.push_str(&format!(" <xmp:ModifyDate>{}</xmp:ModifyDate>\n", xml_escape(d)));
|
|
}
|
|
|
|
out.push_str(" </rdf:Description>\n");
|
|
out.push_str("</rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>\n");
|
|
out.into_bytes()
|
|
}
|
|
}
|
|
|
|
fn first(v: &[String]) -> Option<String> {
|
|
v.first().cloned()
|
|
}
|
|
|
|
fn nonempty(v: &[String]) -> Option<String> {
|
|
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<Option<XmpMetadata>> {
|
|
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 `<dc:title>` and `<title>`; 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"));
|
|
}
|
|
}
|