use std::collections::BTreeMap;
use super::{Error, IsInvoice, Profile};
pub const FILENAMES: &[&str] = &[
"factur-x.xml",
"zugferd-invoice.xml",
"xrechnung.xml",
"order-x.xml",
];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct Xmp {
pub document_type: Option<String>,
pub document_filename: Option<String>,
pub version: Option<String>,
pub conformance_level: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Divergence {
Profile {
xmp: String,
payload: String,
},
Filename {
xmp: String,
actual: String,
},
Relationship {
found: String,
profile: Profile,
},
NoXmp,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Extracted {
pub xml: String,
pub filename: String,
pub profile: Profile,
pub specification_id: Option<String>,
#[cfg(feature = "cii")]
#[cfg_attr(docsrs, doc(cfg(feature = "cii")))]
pub invoice: Option<en16931::Invoice>,
#[cfg(feature = "cii")]
#[cfg_attr(docsrs, doc(cfg(feature = "cii")))]
pub syntax_findings: Vec<String>,
pub relationship: Option<String>,
pub xmp: Xmp,
pub divergence: Vec<Divergence>,
}
pub fn embedded_files(pdf: &[u8]) -> Result<BTreeMap<String, Vec<u8>>, Error> {
Ok(collect_embedded(&lopdf::Document::load_mem(pdf)?))
}
fn collect_embedded(doc: &lopdf::Document) -> BTreeMap<String, Vec<u8>> {
let mut out = BTreeMap::new();
for object in doc.objects.values() {
let Ok(dict) = object.as_dict() else { continue };
let is_filespec = dict
.get(b"Type")
.ok()
.and_then(|t| t.as_name().ok())
.is_some_and(|n| n == b"Filespec");
if !is_filespec {
continue;
}
let Some(name) = filespec_name(dict) else {
continue;
};
let Some(bytes) = filespec_bytes(doc, dict) else {
continue;
};
out.insert(name, bytes);
}
out
}
fn af_relationship(doc: &lopdf::Document, filename: &str) -> Option<String> {
doc.objects.values().find_map(|object| {
let dict = object.as_dict().ok()?;
if dict.get(b"Type").ok()?.as_name().ok()? != b"Filespec" {
return None;
}
if filespec_name(dict)?.eq_ignore_ascii_case(filename) {
let raw = dict.get(b"AFRelationship").ok()?.as_name().ok()?;
Some(String::from_utf8_lossy(raw).into_owned())
} else {
None
}
})
}
fn filespec_name(dict: &lopdf::Dictionary) -> Option<String> {
for key in [&b"UF"[..], &b"F"[..], &b"Desc"[..]] {
if let Ok(obj) = dict.get(key)
&& let Ok(s) = obj.as_str()
{
return Some(String::from_utf8_lossy(s).into_owned());
}
}
None
}
fn filespec_bytes(doc: &lopdf::Document, dict: &lopdf::Dictionary) -> Option<Vec<u8>> {
let ef = dict.get(b"EF").ok()?.as_dict().ok()?;
let stream_ref = ef.get(b"F").or_else(|_| ef.get(b"UF")).ok()?;
let stream = match stream_ref {
lopdf::Object::Reference(id) => doc.get_object(*id).ok()?.as_stream().ok()?,
other => other.as_stream().ok()?,
};
stream
.decompressed_content()
.ok()
.or_else(|| Some(stream.content.clone()))
}
pub fn extract(pdf: &[u8]) -> Result<Extracted, Error> {
let doc = lopdf::Document::load_mem(pdf)?;
let xmp = read_xmp(&doc);
let mut files = collect_embedded(&doc);
let wanted = FILENAMES
.iter()
.find_map(|want| files.keys().find(|k| k.eq_ignore_ascii_case(want)).cloned());
let Some((filename, bytes)) = wanted.and_then(|f| files.remove_entry(&f)) else {
return Err(Error::NoInvoice {
looked_for: FILENAMES,
found: files.into_keys().collect(),
});
};
let xml = String::from_utf8(bytes)?;
let specification_id = specification_id(&xml);
let profile = specification_id
.as_deref()
.map_or(Profile::Unknown, Profile::parse);
#[cfg(feature = "cii")]
let (invoice, syntax_findings) = match crate::cii::from_str(&xml) {
Ok(r) => {
let mut findings = r.unmapped;
findings.extend(r.malformed);
(Some(r.invoice), findings)
}
Err(e) => (None, vec![e.to_string()]),
};
let relationship = af_relationship(&doc, &filename);
Ok(Extracted {
divergence: diverge(
&xmp,
&filename,
profile,
specification_id.as_deref(),
relationship.as_deref(),
),
#[cfg(feature = "cii")]
invoice,
#[cfg(feature = "cii")]
syntax_findings,
xml,
filename,
profile,
specification_id,
relationship,
xmp,
})
}
fn diverge(
xmp: &Xmp,
filename: &str,
profile: Profile,
specification_id: Option<&str>,
relationship: Option<&str>,
) -> Vec<Divergence> {
let mut out = Vec::new();
if relationship.is_some_and(|r| r.eq_ignore_ascii_case("Data"))
&& profile.is_en16931_invoice() == IsInvoice::Yes
{
out.push(Divergence::Relationship {
found: relationship.unwrap_or_default().to_owned(),
profile,
});
}
if *xmp == Xmp::default() {
out.push(Divergence::NoXmp);
return out;
}
if let Some(level) = &xmp.conformance_level {
let claimed = Profile::parse(level);
if claimed != Profile::Unknown && claimed != profile {
out.push(Divergence::Profile {
xmp: level.clone(),
payload: specification_id.unwrap_or("<absent>").to_owned(),
});
}
}
if let Some(declared) = &xmp.document_filename
&& !declared.eq_ignore_ascii_case(filename)
{
out.push(Divergence::Filename {
xmp: declared.clone(),
actual: filename.to_owned(),
});
}
out
}
fn read_xmp(doc: &lopdf::Document) -> Xmp {
let Some(packet) = xmp_packet(doc) else {
return Xmp::default();
};
Xmp {
document_type: xmp_field(&packet, "DocumentType"),
document_filename: xmp_field(&packet, "DocumentFileName"),
version: xmp_field(&packet, "Version"),
conformance_level: xmp_field(&packet, "ConformanceLevel"),
}
}
fn xmp_packet(doc: &lopdf::Document) -> Option<String> {
let catalog = doc.catalog().ok()?;
let meta = catalog.get(b"Metadata").ok()?;
let stream = match meta {
lopdf::Object::Reference(id) => doc.get_object(*id).ok()?.as_stream().ok()?,
other => other.as_stream().ok()?,
};
let bytes = stream
.decompressed_content()
.unwrap_or_else(|_| stream.content.clone());
Some(String::from_utf8_lossy(&bytes).into_owned())
}
fn xmp_field(packet: &str, local_name: &str) -> Option<String> {
let needle = format!(":{local_name}>");
let start = packet.find(&needle)? + needle.len();
let rest = &packet[start..];
let end = rest.find("</")?;
let value = rest[..end].trim();
(!value.is_empty()).then(|| value.to_owned())
}
fn specification_id(xml: &str) -> Option<String> {
let anchor = xml.find("GuidelineSpecifiedDocumentContextParameter")?;
let rest = &xml[anchor..];
let open = rest.find(":ID>").map(|i| i + 4)?;
let close = rest[open..].find("</")?;
let value = rest[open..open + close].trim();
(!value.is_empty()).then(|| value.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bt24_is_found_in_a_cii_fragment() {
let xml = r"<rsm:ExchangedDocumentContext>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>";
assert_eq!(
specification_id(xml).as_deref(),
Some("urn:cen.eu:en16931:2017")
);
}
#[test]
fn bt23_is_not_mistaken_for_bt24() {
let xml = r"<ram:BusinessProcessSpecifiedDocumentContextParameter>
<ram:ID>urn:process</ram:ID>
</ram:BusinessProcessSpecifiedDocumentContextParameter>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:factur-x.eu:1p0:basic</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>";
assert_eq!(
specification_id(xml).as_deref(),
Some("urn:factur-x.eu:1p0:basic")
);
}
#[test]
fn a_payload_without_bt24_reports_none() {
assert_eq!(specification_id("<rsm:CrossIndustryInvoice/>"), None);
assert_eq!(
specification_id("<ram:GuidelineSpecifiedDocumentContextParameter/>"),
None
);
}
#[test]
fn bytes_that_are_not_a_pdf_say_so() {
assert!(matches!(extract(b"not a pdf"), Err(Error::Pdf(_))));
}
}