#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Identification {
pub(crate) part: u8,
pub(crate) conformance: Option<char>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct InfoProperties {
pub(crate) title: Option<String>,
pub(crate) author: Option<String>,
pub(crate) creator_tool: Option<String>,
pub(crate) producer: Option<String>,
pub(crate) keywords: Option<String>,
}
pub(crate) fn is_xmp(packet: &[u8]) -> bool {
let text = String::from_utf8_lossy(packet);
text.contains("<rdf:RDF") || text.contains(":RDF")
}
pub(crate) fn identification(packet: &[u8]) -> Option<Identification> {
let text = String::from_utf8_lossy(packet);
let part = property(&text, "pdfaid:part")?.trim().parse::<u8>().ok()?;
let conformance = property(&text, "pdfaid:conformance")
.and_then(|value| value.trim().chars().next())
.map(|c| c.to_ascii_uppercase());
Some(Identification { part, conformance })
}
pub(crate) fn info_properties(packet: &[u8]) -> InfoProperties {
let text = String::from_utf8_lossy(packet);
InfoProperties {
title: container(&text, "dc:title").or_else(|| property(&text, "dc:title")),
author: container(&text, "dc:creator").or_else(|| property(&text, "dc:creator")),
creator_tool: property(&text, "xmp:CreatorTool"),
producer: property(&text, "pdf:Producer"),
keywords: property(&text, "pdf:Keywords"),
}
}
fn property(text: &str, name: &str) -> Option<String> {
element(text, name).or_else(|| attribute(text, name))
}
fn element(text: &str, name: &str) -> Option<String> {
let open = format!("<{name}");
let start = text.find(&open)?;
let rest = text.get(start + open.len()..)?;
if !rest.starts_with(['>', ' ', '\t', '\r', '\n', '/']) {
return None;
}
let body = rest.get(rest.find('>')? + 1..)?;
let end = body.find(&format!("</{name}>"))?;
Some(unescape(body.get(..end)?))
}
fn attribute(text: &str, name: &str) -> Option<String> {
for quote in ['"', '\''] {
let needle = format!("{name}={quote}");
let Some(start) = text.find(&needle) else {
continue;
};
if text
.get(..start)
.and_then(|before| before.chars().next_back())
.is_some_and(|c| c.is_alphanumeric() || c == ':' || c == '-' || c == '_')
{
continue;
}
let body = text.get(start + needle.len()..)?;
let end = body.find(quote)?;
return Some(unescape(body.get(..end)?));
}
None
}
fn container(text: &str, name: &str) -> Option<String> {
let inner = element(text, name)?;
let raw = raw_element(text, name)?;
let value = element(raw, "rdf:li")?;
Some(if value.is_empty() { inner } else { value })
}
fn raw_element<'a>(text: &'a str, name: &str) -> Option<&'a str> {
let open = format!("<{name}");
let start = text.find(&open)?;
let rest = text.get(start + open.len()..)?;
let body = rest.get(rest.find('>')? + 1..)?;
let end = body.find(&format!("</{name}>"))?;
body.get(..end)
}
fn unescape(value: &str) -> String {
if !value.contains('&') {
return value.trim().to_owned();
}
value
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
.trim()
.to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
const PACKET: &str = r#"<?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:pdfaid="http://www.aiim.org/pdfa/ns/id/">
<pdfaid:part>2</pdfaid:part>
<pdfaid:conformance>B</pdfaid:conformance>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">A & B</rdf:li></rdf:Alt></dc:title>
<dc:creator><rdf:Seq><rdf:li>Ada</rdf:li></rdf:Seq></dc:creator>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
#[test]
fn reads_the_identification_schema_as_elements() {
let id = identification(PACKET.as_bytes()).expect("packet carries pdfaid");
assert_eq!(id.part, 2);
assert_eq!(id.conformance, Some('B'));
}
#[test]
fn reads_the_identification_schema_as_attributes() {
let compact =
r#"<rdf:RDF><rdf:Description pdfaid:part="1" pdfaid:conformance="b"/></rdf:RDF>"#;
let id = identification(compact.as_bytes()).expect("compact form is read too");
assert_eq!(id.part, 1);
assert_eq!(id.conformance, Some('B'));
}
#[test]
fn unwraps_containers_and_entities() {
let props = info_properties(PACKET.as_bytes());
assert_eq!(props.title.as_deref(), Some("A & B"));
assert_eq!(props.author.as_deref(), Some("Ada"));
}
#[test]
fn a_longer_name_with_the_same_prefix_does_not_match() {
let decoy = r"<rdf:RDF><pdfaid:partial>9</pdfaid:partial></rdf:RDF>";
assert!(identification(decoy.as_bytes()).is_none());
}
#[test]
fn a_packet_that_is_not_rdf_is_not_xmp() {
assert!(is_xmp(PACKET.as_bytes()));
assert!(!is_xmp(b"\x89PNG\r\n\x1a\n"));
}
}