use crate::error::PdfError;
use crate::objects::{Dict, Object};
use crate::reader::document::DocumentReader;
use crate::reader::xmp::XmpPacket;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PdfACatalogSignals {
pub mark_info_marked: bool,
pub mark_info_user_properties: bool,
pub mark_info_suspects: bool,
pub has_struct_tree_root: bool,
pub catalog_lang: Option<String>,
pub output_intent_count: usize,
pub has_xmp_metadata: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PdfAConformance {
pub declared: Option<(u8, String)>,
pub structurally_sound: bool,
pub claim_inconsistent: bool,
pub inconsistencies: Vec<String>,
}
impl PdfAConformance {
pub fn from_signals_and_xmp(sig: &PdfACatalogSignals, xmp: Option<&XmpPacket>) -> Self {
let declared = xmp.and_then(|x| {
let part = x.pdfaid_part?;
let conf = x.pdfaid_conformance.clone().unwrap_or_default();
Some((part, conf))
});
let mut inconsistencies = Vec::new();
let mut sound = true;
if let Some((part, ref conf)) = declared {
if !sig.has_xmp_metadata {
inconsistencies.push(
"PDF/A claim in XMP but Catalog /Metadata reference is absent (§6.7)".into(),
);
sound = false;
}
if sig.output_intent_count == 0 {
inconsistencies.push(format!(
"PDF/A-{part}{conf}: Catalog /OutputIntents missing (§6.2.2)"
));
sound = false;
}
if conf.eq_ignore_ascii_case("A") {
if !sig.mark_info_marked {
inconsistencies.push(format!(
"PDF/A-{part}A claims accessibility but Catalog /MarkInfo /Marked is not true (§6.8.3)"
));
sound = false;
}
if !sig.has_struct_tree_root {
inconsistencies.push(format!(
"PDF/A-{part}A claims accessibility but Catalog /StructTreeRoot is absent (§6.8.2)"
));
sound = false;
}
if sig.catalog_lang.is_none() {
inconsistencies.push(format!(
"PDF/A-{part}A claims accessibility but Catalog /Lang is absent (recommended)"
));
}
}
}
let claim_inconsistent = declared.is_some() && !inconsistencies.is_empty();
Self {
declared,
structurally_sound: sound,
claim_inconsistent,
inconsistencies,
}
}
pub fn is_declared(&self) -> bool {
self.declared.is_some()
}
pub fn designator(&self) -> Option<String> {
let (part, conf) = self.declared.as_ref()?;
if conf.is_empty() {
None
} else {
Some(format!("{part}{conf}"))
}
}
}
pub fn pdfa_signals(reader: &mut DocumentReader<'_>) -> Result<PdfACatalogSignals, PdfError> {
let root_id = reader.xref().root()?;
let catalog = reader.resolve(root_id)?;
let Object::Dict(catalog) = catalog else {
return Ok(PdfACatalogSignals::default());
};
let mut sig = PdfACatalogSignals::default();
if let Some(mark_info_obj) = lookup(&catalog, "MarkInfo").cloned() {
let mark_info = reader.deref(mark_info_obj)?;
if let Object::Dict(d) = mark_info {
sig.mark_info_marked = matches!(lookup(&d, "Marked"), Some(Object::Bool(true)));
sig.mark_info_user_properties =
matches!(lookup(&d, "UserProperties"), Some(Object::Bool(true)));
sig.mark_info_suspects = matches!(lookup(&d, "Suspects"), Some(Object::Bool(true)));
}
}
sig.has_struct_tree_root = lookup(&catalog, "StructTreeRoot").is_some();
sig.catalog_lang = match lookup(&catalog, "Lang") {
Some(Object::LiteralString(b)) | Some(Object::HexString(b)) => {
Some(String::from_utf8_lossy(b).into_owned())
}
_ => None,
};
if let Some(oi_obj) = lookup(&catalog, "OutputIntents").cloned() {
let oi_obj = reader.deref(oi_obj)?;
if let Object::Array(items) = oi_obj {
sig.output_intent_count = items.len();
}
}
sig.has_xmp_metadata = lookup(&catalog, "Metadata").is_some();
Ok(sig)
}
fn lookup<'d>(d: &'d Dict, k: &str) -> Option<&'d Object> {
d.entries().iter().find(|(kk, _)| kk == k).map(|(_, v)| v)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::writer::write_pdf_from_scene;
use oxideav_core::time::TimeBase;
use oxideav_core::vector::{
FillRule, Group, Node, Paint, Path, PathCommand, PathNode, Point, Rgba, VectorFrame,
};
use oxideav_scene::{Page, Scene};
fn empty_page() -> Page {
let mut p = Path::new();
p.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
p.commands.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
p.commands.push(PathCommand::Close);
let frame = VectorFrame {
width: 100.0,
height: 100.0,
view_box: None,
root: Group {
children: vec![Node::Path(PathNode {
path: p,
fill: Some(Paint::Solid(Rgba::opaque(0, 0, 0))),
stroke: None,
fill_rule: FillRule::NonZero,
})],
..Group::default()
},
pts: None,
time_base: TimeBase::new(1, 1),
};
let mut page = Page::new(100.0, 100.0);
page.content = frame;
page
}
#[test]
fn writer_output_has_no_pdfa_signals() {
let scene = Scene {
pages: Some(vec![empty_page()]),
..Scene::default()
};
let pdf = write_pdf_from_scene(&scene).expect("write_pdf");
let mut reader = DocumentReader::open(&pdf).expect("open");
let sig = pdfa_signals(&mut reader).expect("signals");
assert!(!sig.mark_info_marked);
assert!(!sig.has_struct_tree_root);
assert!(sig.catalog_lang.is_none());
assert_eq!(sig.output_intent_count, 0);
assert!(!sig.has_xmp_metadata);
}
#[test]
fn unclaimed_doc_yields_undeclared_conformance() {
let sig = PdfACatalogSignals::default();
let conformance = PdfAConformance::from_signals_and_xmp(&sig, None);
assert!(!conformance.is_declared());
assert!(conformance.structurally_sound);
assert!(!conformance.claim_inconsistent);
assert!(conformance.inconsistencies.is_empty());
assert!(conformance.designator().is_none());
}
#[test]
fn claim_without_outputintents_flags_inconsistency() {
let sig = PdfACatalogSignals {
has_xmp_metadata: true,
output_intent_count: 0,
..Default::default()
};
let xmp = XmpPacket {
pdfaid_part: Some(2),
pdfaid_conformance: Some("B".into()),
..XmpPacket::default()
};
let c = PdfAConformance::from_signals_and_xmp(&sig, Some(&xmp));
assert!(c.is_declared());
assert!(c.claim_inconsistent);
assert!(!c.structurally_sound);
assert!(c
.inconsistencies
.iter()
.any(|m| m.contains("OutputIntents")));
}
#[test]
fn a_level_without_marked_flags_accessibility_gap() {
let sig = PdfACatalogSignals {
has_xmp_metadata: true,
output_intent_count: 1,
mark_info_marked: false,
has_struct_tree_root: false,
..Default::default()
};
let xmp = XmpPacket {
pdfaid_part: Some(2),
pdfaid_conformance: Some("A".into()),
..XmpPacket::default()
};
let c = PdfAConformance::from_signals_and_xmp(&sig, Some(&xmp));
assert!(c.claim_inconsistent);
assert!(c
.inconsistencies
.iter()
.any(|m| m.contains("Marked is not true")));
assert!(c
.inconsistencies
.iter()
.any(|m| m.contains("StructTreeRoot is absent")));
}
#[test]
fn a_level_with_full_structure_is_sound() {
let sig = PdfACatalogSignals {
has_xmp_metadata: true,
output_intent_count: 1,
mark_info_marked: true,
has_struct_tree_root: true,
catalog_lang: Some("en".into()),
..Default::default()
};
let xmp = XmpPacket {
pdfaid_part: Some(3),
pdfaid_conformance: Some("A".into()),
..XmpPacket::default()
};
let c = PdfAConformance::from_signals_and_xmp(&sig, Some(&xmp));
assert!(c.is_declared());
assert!(c.structurally_sound);
assert!(!c.claim_inconsistent);
assert!(c.inconsistencies.is_empty());
assert_eq!(c.designator().as_deref(), Some("3A"));
}
#[test]
fn b_level_no_structural_requirements_beyond_oi() {
let sig = PdfACatalogSignals {
has_xmp_metadata: true,
output_intent_count: 1,
mark_info_marked: false,
has_struct_tree_root: false,
..Default::default()
};
let xmp = XmpPacket {
pdfaid_part: Some(2),
pdfaid_conformance: Some("B".into()),
..XmpPacket::default()
};
let c = PdfAConformance::from_signals_and_xmp(&sig, Some(&xmp));
assert!(c.is_declared());
assert!(c.structurally_sound);
assert!(!c.claim_inconsistent);
}
#[test]
fn case_insensitive_conformance_match() {
let sig = PdfACatalogSignals {
has_xmp_metadata: true,
output_intent_count: 1,
mark_info_marked: false,
has_struct_tree_root: false,
..Default::default()
};
let xmp = XmpPacket {
pdfaid_part: Some(1),
pdfaid_conformance: Some("a".into()),
..XmpPacket::default()
};
let c = PdfAConformance::from_signals_and_xmp(&sig, Some(&xmp));
assert!(c.claim_inconsistent);
}
}