forme-pdf 0.9.1

A page-native PDF rendering engine. Layout INTO pages, not onto an infinite canvas.
Documentation
//! # XMP Metadata for PDF/A and PDF/UA
//!
//! Generates the XMP (Extensible Metadata Platform) XML packet required by
//! PDF/A and PDF/UA. Written as an uncompressed metadata stream referenced
//! from the Catalog via `/Metadata`.

use crate::model::{Metadata, PdfAConformance};

/// Generate XMP metadata XML for PDF/A and/or PDF/UA documents.
pub fn generate_xmp(
    metadata: &Metadata,
    conformance: Option<&PdfAConformance>,
    pdf_ua: bool,
) -> String {
    let title = metadata.title.as_deref().unwrap_or("Untitled");
    let creator = metadata.creator.as_deref().unwrap_or("Forme");

    // Build namespace declarations
    let mut namespaces = vec![
        r#"xmlns:dc="http://purl.org/dc/elements/1.1/""#.to_string(),
        r#"xmlns:xmp="http://ns.adobe.com/xap/1.0/""#.to_string(),
        r#"xmlns:pdf="http://ns.adobe.com/pdf/1.3/""#.to_string(),
    ];
    if conformance.is_some() {
        namespaces.push(r#"xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/""#.to_string());
    }
    if pdf_ua {
        namespaces.push(r#"xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/""#.to_string());
    }

    // Build conformance entries
    let mut entries = String::new();
    if let Some(conf) = conformance {
        let (part, level) = match conf {
            PdfAConformance::A2a => ("2", "A"),
            PdfAConformance::A2b => ("2", "B"),
        };
        entries.push_str(&format!(
            "      <pdfaid:part>{}</pdfaid:part>\n      <pdfaid:conformance>{}</pdfaid:conformance>\n",
            part, level
        ));
    }
    if pdf_ua {
        entries.push_str("      <pdfuaid:part>1</pdfuaid:part>\n");
    }

    let ns_str = namespaces
        .iter()
        .enumerate()
        .map(|(i, ns)| {
            if i == 0 {
                format!("\n      {}", ns)
            } else {
                format!("      {}", ns)
            }
        })
        .collect::<Vec<_>>()
        .join("\n");

    // XMP packet — must not be compressed per PDF/A spec
    format!(
        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=""{ns}>
      <dc:title>
        <rdf:Alt>
          <rdf:li xml:lang="x-default">{title}</rdf:li>
        </rdf:Alt>
      </dc:title>
      <dc:creator>
        <rdf:Seq>
          <rdf:li>{creator}</rdf:li>
        </rdf:Seq>
      </dc:creator>
      <xmp:CreatorTool>Forme</xmp:CreatorTool>
      <pdf:Producer>Forme 0.6</pdf:Producer>
{entries}    </rdf:Description>
  </rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#,
        ns = ns_str,
        title = xml_escape(title),
        creator = xml_escape(creator),
        entries = entries,
    )
}

/// Escape XML special characters.
fn xml_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&apos;")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_xmp_contains_pdfa_conformance() {
        let metadata = Metadata {
            title: Some("Test".to_string()),
            ..Default::default()
        };
        let xmp = generate_xmp(&metadata, Some(&PdfAConformance::A2a), false);
        assert!(xmp.contains("<pdfaid:part>2</pdfaid:part>"));
        assert!(xmp.contains("<pdfaid:conformance>A</pdfaid:conformance>"));
        assert!(!xmp.contains("pdfuaid"));
    }

    #[test]
    fn test_xmp_escapes_special_chars() {
        let metadata = Metadata {
            title: Some("A & B <C>".to_string()),
            ..Default::default()
        };
        let xmp = generate_xmp(&metadata, Some(&PdfAConformance::A2b), false);
        assert!(xmp.contains("A &amp; B &lt;C&gt;"));
        assert!(xmp.contains("<pdfaid:conformance>B</pdfaid:conformance>"));
    }

    #[test]
    fn test_xmp_contains_pdfua_part() {
        let metadata = Metadata {
            title: Some("Accessible".to_string()),
            ..Default::default()
        };
        let xmp = generate_xmp(&metadata, None, true);
        assert!(xmp.contains("<pdfuaid:part>1</pdfuaid:part>"));
        assert!(xmp.contains("xmlns:pdfuaid"));
        assert!(!xmp.contains("pdfaid"));
    }

    #[test]
    fn test_xmp_both_pdfa_and_pdfua() {
        let metadata = Metadata {
            title: Some("Both".to_string()),
            ..Default::default()
        };
        let xmp = generate_xmp(&metadata, Some(&PdfAConformance::A2a), true);
        assert!(xmp.contains("<pdfaid:part>2</pdfaid:part>"));
        assert!(xmp.contains("<pdfaid:conformance>A</pdfaid:conformance>"));
        assert!(xmp.contains("<pdfuaid:part>1</pdfuaid:part>"));
        assert!(xmp.contains("xmlns:pdfaid"));
        assert!(xmp.contains("xmlns:pdfuaid"));
    }

    #[test]
    fn test_xmp_pdfua_only_no_pdfa_entries() {
        let metadata = Metadata::default();
        let xmp = generate_xmp(&metadata, None, true);
        assert!(xmp.contains("<pdfuaid:part>1</pdfuaid:part>"));
        assert!(!xmp.contains("<pdfaid:part>"));
        assert!(!xmp.contains("<pdfaid:conformance>"));
    }
}