use crate::PdfaLevel;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct InfoFields {
pub(crate) title: Option<String>,
pub(crate) author: Option<String>,
pub(crate) creator: Option<String>,
pub(crate) producer: Option<String>,
pub(crate) keywords: Option<String>,
pub(crate) create_date: Option<String>,
pub(crate) modify_date: Option<String>,
}
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c if (c as u32) < 0x20 && c != '\t' && c != '\n' && c != '\r' => {}
c => out.push(c),
}
}
out
}
fn element(out: &mut String, name: &str, value: Option<&String>) {
let Some(value) = value.filter(|v| !v.is_empty()) else {
return;
};
out.push_str(" <");
out.push_str(name);
out.push('>');
out.push_str(&escape(value));
out.push_str("</");
out.push_str(name);
out.push_str(">\n");
}
fn date_element(out: &mut String, name: &str, value: Option<&String>) {
let Some(value) = value.filter(|v| is_iso8601(v)) else {
return;
};
element(out, name, Some(&value.clone()));
}
fn is_iso8601(text: &str) -> bool {
let bytes = text.as_bytes();
if bytes.len() < 4 || !bytes[..4].iter().all(u8::is_ascii_digit) {
return false;
}
bytes.len() == 4 || bytes[4] == b'-'
}
pub(crate) fn packet(level: PdfaLevel, info: &InfoFields) -> Vec<u8> {
let mut out = String::with_capacity(1024);
out.push_str("<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n");
out.push_str("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" x:xmptk=\"pdfrum\">\n");
out.push_str(" <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n");
out.push_str(
" <rdf:Description rdf:about=\"\" xmlns:pdfaid=\"http://www.aiim.org/pdfa/ns/id/\">\n",
);
out.push_str(" <pdfaid:part>");
out.push_str(&level.part().to_string());
out.push_str("</pdfaid:part>\n");
out.push_str(" <pdfaid:conformance>B</pdfaid:conformance>\n");
out.push_str(" </rdf:Description>\n");
if info.title.as_ref().is_some_and(|t| !t.is_empty())
|| info.author.as_ref().is_some_and(|a| !a.is_empty())
{
out.push_str(
" <rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n",
);
if let Some(title) = info.title.as_ref().filter(|t| !t.is_empty()) {
out.push_str(" <dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">");
out.push_str(&escape(title));
out.push_str("</rdf:li></rdf:Alt></dc:title>\n");
}
if let Some(author) = info.author.as_ref().filter(|a| !a.is_empty()) {
out.push_str(" <dc:creator><rdf:Seq><rdf:li>");
out.push_str(&escape(author));
out.push_str("</rdf:li></rdf:Seq></dc:creator>\n");
}
out.push_str(" </rdf:Description>\n");
}
out.push_str(" <rdf:Description rdf:about=\"\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\">\n");
element(&mut out, "xmp:CreatorTool", info.creator.as_ref());
date_element(&mut out, "xmp:CreateDate", info.create_date.as_ref());
date_element(&mut out, "xmp:ModifyDate", info.modify_date.as_ref());
out.push_str(" </rdf:Description>\n");
out.push_str(" <rdf:Description rdf:about=\"\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\">\n");
element(&mut out, "pdf:Producer", info.producer.as_ref());
element(&mut out, "pdf:Keywords", info.keywords.as_ref());
out.push_str(" </rdf:Description>\n");
out.push_str(" </rdf:RDF>\n");
out.push_str("</x:xmpmeta>\n");
out.push_str("<?xpacket end=\"w\"?>\n");
out.into_bytes()
}
#[cfg(test)]
mod tests {
use super::{InfoFields, is_iso8601, packet};
use crate::PdfaLevel;
fn text(level: PdfaLevel, info: &InfoFields) -> String {
String::from_utf8(packet(level, info)).expect("the packet is UTF-8 by construction")
}
#[test]
fn the_identification_schema_names_the_level() {
let one = text(PdfaLevel::A1b, &InfoFields::default());
assert!(one.contains("<pdfaid:part>1</pdfaid:part>"));
assert!(one.contains("<pdfaid:conformance>B</pdfaid:conformance>"));
let two = text(PdfaLevel::A2b, &InfoFields::default());
assert!(two.contains("<pdfaid:part>2</pdfaid:part>"));
}
#[test]
fn the_checker_reads_the_packet_the_writer_writes() {
let doc = crate::Document::open("tests/fixtures/text_form.pdf").expect("fixture opens");
let _ = doc;
let bytes = packet(PdfaLevel::A2b, &InfoFields::default());
let s = String::from_utf8(bytes).expect("utf-8");
assert!(s.contains("<rdf:RDF"));
assert!(s.contains("pdfaid:part"));
}
#[test]
fn reserved_characters_are_escaped() {
let info = InfoFields {
title: Some("a & b < c > d".to_owned()),
..InfoFields::default()
};
let out = text(PdfaLevel::A2b, &info);
assert!(out.contains("a & b < c > d"));
assert!(!out.contains("a & b"));
}
#[test]
fn control_characters_are_dropped_rather_than_escaped() {
let info = InfoFields {
producer: Some("bad\u{0}producer".to_owned()),
..InfoFields::default()
};
assert!(text(PdfaLevel::A2b, &info).contains("<pdf:Producer>badproducer"));
}
#[test]
fn an_empty_property_is_written_as_absent() {
let info = InfoFields {
producer: Some(String::new()),
..InfoFields::default()
};
assert!(!text(PdfaLevel::A2b, &info).contains("pdf:Producer"));
}
#[test]
fn a_malformed_date_is_dropped_rather_than_written() {
let info = InfoFields {
create_date: Some("not a date".to_owned()),
modify_date: Some("2026-09-07T12:00:00Z".to_owned()),
..InfoFields::default()
};
let out = text(PdfaLevel::A2b, &info);
assert!(!out.contains("CreateDate"));
assert!(out.contains("<xmp:ModifyDate>2026-09-07T12:00:00Z"));
}
#[test]
fn iso8601_accepts_the_shapes_xmp_permits() {
assert!(is_iso8601("2026"));
assert!(is_iso8601("2026-09"));
assert!(is_iso8601("2026-09-07T12:00:00+01:00"));
assert!(!is_iso8601("D:20260907120000Z"));
assert!(!is_iso8601(""));
assert!(!is_iso8601("26-09-07"));
}
#[test]
fn the_dublin_core_properties_are_rdf_containers() {
let info = InfoFields {
title: Some("T".to_owned()),
author: Some("A".to_owned()),
..InfoFields::default()
};
let out = text(PdfaLevel::A2b, &info);
assert!(out.contains("<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">T</rdf:li>"));
assert!(out.contains("<dc:creator><rdf:Seq><rdf:li>A</rdf:li>"));
}
#[test]
fn the_dublin_core_block_is_absent_when_it_would_be_empty() {
assert!(!text(PdfaLevel::A2b, &InfoFields::default()).contains("purl.org/dc"));
}
}