nigig-org/crates/apps/pdf/pdf-document/src/xmp.rs
andodeki b1045d79d4
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
feat(pdf): graphics/document port progress + android gates
- 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.
2026-09-11 05:09:11 +03:00

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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn unescape(s: &str) -> String {
s.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
}
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"));
}
}