Skip to main content

oxideav_pdf/reader/
pdfa.rs

1//! Round-27 — PDF/A conformance detection beyond the XMP tag
2//! (ISO 19005-1..4 §6.x — identification + structural requirements).
3//!
4//! Round 26 surfaced `pdfaid:part` / `pdfaid:conformance` from the
5//! XMP `/Metadata` packet only. ISO 19005 conformance, however, also
6//! requires:
7//!
8//! * **`/MarkInfo /Marked true`** on the catalog (PDF/A-1a / -2a / -3a
9//!   "accessibility" levels — ISO 19005-1 §6.8.3 / 19005-2 §6.7.7).
10//! * **`/StructTreeRoot`** on the catalog (same — accessible logical
11//!   structure tree).
12//! * **`/Lang`** on the catalog (recommended for `A` conformance).
13//!
14//! Round 27 surfaces ALL of these so a caller checking
15//! "is this REALLY PDF/A?" can cross-verify the XMP tag against the
16//! structural signals. A doc that claims PDF/A-1a in XMP but doesn't
17//! carry `/MarkInfo /Marked true` is technically non-conformant
18//! (Adobe's preflight reports it as such); this module lets you spot
19//! that mismatch.
20//!
21//! Scope: detection only. We do NOT validate the structure tree's
22//! tagging contents (P / H1..H6 / Span / Figure etc.); that's a
23//! standards-test problem orthogonal to format identification.
24
25use crate::error::PdfError;
26use crate::objects::{Dict, Object};
27use crate::reader::document::DocumentReader;
28use crate::reader::xmp::XmpPacket;
29
30/// Structural PDF/A signals from the catalog, independent of the
31/// XMP packet. Surfaced by [`pdfa_signals`].
32///
33/// Use [`PdfAConformance::from_signals_and_xmp`] to combine these
34/// with the XMP packet's `pdfaid:part` / `pdfaid:conformance`
35/// declaration into a single conformance picture.
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct PdfACatalogSignals {
38    /// `/MarkInfo /Marked` — true when the catalog declares the
39    /// document is logically structured ("tagged PDF" per
40    /// §14.7.2). Required by PDF/A `A`-level conformance.
41    pub mark_info_marked: bool,
42    /// `/MarkInfo /UserProperties` — true when user-properties are
43    /// declared (PDF 1.6+). Not required by any PDF/A level, but
44    /// surfaced for completeness.
45    pub mark_info_user_properties: bool,
46    /// `/MarkInfo /Suspects` — true when the document has suspect
47    /// markings (PDF 1.6+). Recommended FALSE for PDF/A-2a / -3a.
48    pub mark_info_suspects: bool,
49    /// `/StructTreeRoot` reference present on the catalog. The
50    /// dict's contents (the actual tag tree) is NOT walked here.
51    pub has_struct_tree_root: bool,
52    /// `/Lang` on the catalog — language identifier per BCP 47.
53    /// Recommended for PDF/A `A` conformance.
54    pub catalog_lang: Option<String>,
55    /// `/OutputIntents` on the catalog — required by PDF/A for
56    /// embedded ICC output intent (§6.2.2). Surfaced as the count
57    /// of intent dicts found.
58    pub output_intent_count: usize,
59    /// `/Metadata` reference on the catalog. PDF/A requires an
60    /// embedded XMP packet (§6.7); this is `true` when present.
61    pub has_xmp_metadata: bool,
62}
63
64/// Resolved PDF/A conformance picture, combining XMP claims with
65/// catalog signals. See [`PdfAConformance::from_signals_and_xmp`].
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct PdfAConformance {
68    /// What the XMP packet declares — `Some((part, conformance))`
69    /// when `pdfaid:part` is set; `None` when the document does not
70    /// claim PDF/A in XMP.
71    pub declared: Option<(u8, String)>,
72    /// True when the structural signals are sufficient for the
73    /// declared conformance. False when the document claims an
74    /// `A`-level (accessibility) conformance but lacks the tagged-
75    /// PDF prerequisites.
76    pub structurally_sound: bool,
77    /// True when the document claims PDF/A in XMP but is missing
78    /// one or more structural prerequisites — caller's signal to
79    /// surface "PDF/A claim untrustworthy" diagnostic.
80    pub claim_inconsistent: bool,
81    /// Free-form description of any inconsistency found. Empty
82    /// vec when the claim and structure agree.
83    pub inconsistencies: Vec<String>,
84}
85
86impl PdfAConformance {
87    /// Combine catalog signals with the XMP packet's claim.
88    ///
89    /// The conformance rules per ISO 19005-x:
90    /// * Any `pdfaid:part` requires `/Metadata` (XMP packet present),
91    ///   `/OutputIntents` ≥ 1 (ISO 19005-1 §6.2.2), `/StructTreeRoot`
92    ///   for `A` conformance levels and `/MarkInfo /Marked true`.
93    /// * `B` (basic) conformance ⇒ no structural requirements.
94    /// * `U` (Unicode) conformance ⇒ no structural requirements
95    ///   (extra ToUnicode coverage; doesn't bubble to catalog).
96    /// * `A` (accessibility) conformance ⇒ requires tagged PDF.
97    pub fn from_signals_and_xmp(sig: &PdfACatalogSignals, xmp: Option<&XmpPacket>) -> Self {
98        let declared = xmp.and_then(|x| {
99            let part = x.pdfaid_part?;
100            let conf = x.pdfaid_conformance.clone().unwrap_or_default();
101            Some((part, conf))
102        });
103        let mut inconsistencies = Vec::new();
104        let mut sound = true;
105
106        if let Some((part, ref conf)) = declared {
107            // /Metadata must be present per ISO 19005-1 §6.7.
108            if !sig.has_xmp_metadata {
109                inconsistencies.push(
110                    "PDF/A claim in XMP but Catalog /Metadata reference is absent (§6.7)".into(),
111                );
112                sound = false;
113            }
114            // /OutputIntents required for every part.
115            if sig.output_intent_count == 0 {
116                inconsistencies.push(format!(
117                    "PDF/A-{part}{conf}: Catalog /OutputIntents missing (§6.2.2)"
118                ));
119                sound = false;
120            }
121            // `A` (accessibility) conformance — tagged PDF prerequisites.
122            if conf.eq_ignore_ascii_case("A") {
123                if !sig.mark_info_marked {
124                    inconsistencies.push(format!(
125                        "PDF/A-{part}A claims accessibility but Catalog /MarkInfo /Marked is not true (§6.8.3)"
126                    ));
127                    sound = false;
128                }
129                if !sig.has_struct_tree_root {
130                    inconsistencies.push(format!(
131                        "PDF/A-{part}A claims accessibility but Catalog /StructTreeRoot is absent (§6.8.2)"
132                    ));
133                    sound = false;
134                }
135                if sig.catalog_lang.is_none() {
136                    inconsistencies.push(format!(
137                        "PDF/A-{part}A claims accessibility but Catalog /Lang is absent (recommended)"
138                    ));
139                    // /Lang is recommended, not required — don't fail
140                    // the structural soundness flag for this alone.
141                }
142            }
143        }
144
145        let claim_inconsistent = declared.is_some() && !inconsistencies.is_empty();
146        Self {
147            declared,
148            structurally_sound: sound,
149            claim_inconsistent,
150            inconsistencies,
151        }
152    }
153
154    /// True when the document claims PDF/A in XMP. Synonym for
155    /// `self.declared.is_some()`.
156    pub fn is_declared(&self) -> bool {
157        self.declared.is_some()
158    }
159
160    /// Convenience: `"1A"` / `"2B"` / `"3U"` style designator —
161    /// returns `Some(s)` when both part and conformance are known.
162    pub fn designator(&self) -> Option<String> {
163        let (part, conf) = self.declared.as_ref()?;
164        if conf.is_empty() {
165            None
166        } else {
167            Some(format!("{part}{conf}"))
168        }
169    }
170}
171
172/// Surface the structural PDF/A signals from the catalog.
173///
174/// Does NOT consult the XMP packet — combine with
175/// [`crate::reader::DocumentReader::xmp_packet`] for the full
176/// conformance picture via [`PdfAConformance::from_signals_and_xmp`].
177pub fn pdfa_signals(reader: &mut DocumentReader<'_>) -> Result<PdfACatalogSignals, PdfError> {
178    let root_id = reader.xref().root()?;
179    let catalog = reader.resolve(root_id)?;
180    let Object::Dict(catalog) = catalog else {
181        return Ok(PdfACatalogSignals::default());
182    };
183
184    let mut sig = PdfACatalogSignals::default();
185
186    // /MarkInfo (§14.7 Table 321). May be a dict OR an indirect ref.
187    if let Some(mark_info_obj) = lookup(&catalog, "MarkInfo").cloned() {
188        let mark_info = reader.deref(mark_info_obj)?;
189        if let Object::Dict(d) = mark_info {
190            sig.mark_info_marked = matches!(lookup(&d, "Marked"), Some(Object::Bool(true)));
191            sig.mark_info_user_properties =
192                matches!(lookup(&d, "UserProperties"), Some(Object::Bool(true)));
193            sig.mark_info_suspects = matches!(lookup(&d, "Suspects"), Some(Object::Bool(true)));
194        }
195    }
196
197    // /StructTreeRoot (§14.7.2 Table 322). Presence-check only.
198    sig.has_struct_tree_root = lookup(&catalog, "StructTreeRoot").is_some();
199
200    // /Lang on the catalog (§14.9.2.2).
201    sig.catalog_lang = match lookup(&catalog, "Lang") {
202        Some(Object::LiteralString(b)) | Some(Object::HexString(b)) => {
203            Some(String::from_utf8_lossy(b).into_owned())
204        }
205        _ => None,
206    };
207
208    // /OutputIntents (§14.11.5 Table 388). Required for PDF/A.
209    if let Some(oi_obj) = lookup(&catalog, "OutputIntents").cloned() {
210        let oi_obj = reader.deref(oi_obj)?;
211        if let Object::Array(items) = oi_obj {
212            sig.output_intent_count = items.len();
213        }
214    }
215
216    // /Metadata (§14.3.2). The xmp_metadata accessor returns Some
217    // when this is wired through — re-implementing the resolution
218    // here would double-fetch the stream; we just check presence.
219    sig.has_xmp_metadata = lookup(&catalog, "Metadata").is_some();
220
221    Ok(sig)
222}
223
224fn lookup<'d>(d: &'d Dict, k: &str) -> Option<&'d Object> {
225    d.entries().iter().find(|(kk, _)| kk == k).map(|(_, v)| v)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::writer::write_pdf_from_scene;
232    use oxideav_core::time::TimeBase;
233    use oxideav_core::vector::{
234        FillRule, Group, Node, Paint, Path, PathCommand, PathNode, Point, Rgba, VectorFrame,
235    };
236    use oxideav_scene::{Page, Scene};
237
238    fn empty_page() -> Page {
239        let mut p = Path::new();
240        p.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
241        p.commands.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
242        p.commands.push(PathCommand::Close);
243        let frame = VectorFrame {
244            width: 100.0,
245            height: 100.0,
246            view_box: None,
247            root: Group {
248                children: vec![Node::Path(PathNode {
249                    path: p,
250                    fill: Some(Paint::Solid(Rgba::opaque(0, 0, 0))),
251                    stroke: None,
252                    fill_rule: FillRule::NonZero,
253                })],
254                ..Group::default()
255            },
256            pts: None,
257            time_base: TimeBase::new(1, 1),
258        };
259        let mut page = Page::new(100.0, 100.0);
260        page.content = frame;
261        page
262    }
263
264    #[test]
265    fn writer_output_has_no_pdfa_signals() {
266        let scene = Scene {
267            pages: Some(vec![empty_page()]),
268            ..Scene::default()
269        };
270        let pdf = write_pdf_from_scene(&scene).expect("write_pdf");
271        let mut reader = DocumentReader::open(&pdf).expect("open");
272        let sig = pdfa_signals(&mut reader).expect("signals");
273        assert!(!sig.mark_info_marked);
274        assert!(!sig.has_struct_tree_root);
275        assert!(sig.catalog_lang.is_none());
276        assert_eq!(sig.output_intent_count, 0);
277        assert!(!sig.has_xmp_metadata);
278    }
279
280    #[test]
281    fn unclaimed_doc_yields_undeclared_conformance() {
282        let sig = PdfACatalogSignals::default();
283        let conformance = PdfAConformance::from_signals_and_xmp(&sig, None);
284        assert!(!conformance.is_declared());
285        assert!(conformance.structurally_sound);
286        assert!(!conformance.claim_inconsistent);
287        assert!(conformance.inconsistencies.is_empty());
288        assert!(conformance.designator().is_none());
289    }
290
291    #[test]
292    fn claim_without_outputintents_flags_inconsistency() {
293        let sig = PdfACatalogSignals {
294            has_xmp_metadata: true,
295            output_intent_count: 0,
296            ..Default::default()
297        };
298        let xmp = XmpPacket {
299            pdfaid_part: Some(2),
300            pdfaid_conformance: Some("B".into()),
301            ..XmpPacket::default()
302        };
303        let c = PdfAConformance::from_signals_and_xmp(&sig, Some(&xmp));
304        assert!(c.is_declared());
305        assert!(c.claim_inconsistent);
306        assert!(!c.structurally_sound);
307        assert!(c
308            .inconsistencies
309            .iter()
310            .any(|m| m.contains("OutputIntents")));
311    }
312
313    #[test]
314    fn a_level_without_marked_flags_accessibility_gap() {
315        let sig = PdfACatalogSignals {
316            has_xmp_metadata: true,
317            output_intent_count: 1,
318            mark_info_marked: false,
319            has_struct_tree_root: false,
320            ..Default::default()
321        };
322        let xmp = XmpPacket {
323            pdfaid_part: Some(2),
324            pdfaid_conformance: Some("A".into()),
325            ..XmpPacket::default()
326        };
327        let c = PdfAConformance::from_signals_and_xmp(&sig, Some(&xmp));
328        assert!(c.claim_inconsistent);
329        assert!(c
330            .inconsistencies
331            .iter()
332            .any(|m| m.contains("Marked is not true")));
333        assert!(c
334            .inconsistencies
335            .iter()
336            .any(|m| m.contains("StructTreeRoot is absent")));
337    }
338
339    #[test]
340    fn a_level_with_full_structure_is_sound() {
341        let sig = PdfACatalogSignals {
342            has_xmp_metadata: true,
343            output_intent_count: 1,
344            mark_info_marked: true,
345            has_struct_tree_root: true,
346            catalog_lang: Some("en".into()),
347            ..Default::default()
348        };
349        let xmp = XmpPacket {
350            pdfaid_part: Some(3),
351            pdfaid_conformance: Some("A".into()),
352            ..XmpPacket::default()
353        };
354        let c = PdfAConformance::from_signals_and_xmp(&sig, Some(&xmp));
355        assert!(c.is_declared());
356        assert!(c.structurally_sound);
357        assert!(!c.claim_inconsistent);
358        assert!(c.inconsistencies.is_empty());
359        assert_eq!(c.designator().as_deref(), Some("3A"));
360    }
361
362    #[test]
363    fn b_level_no_structural_requirements_beyond_oi() {
364        let sig = PdfACatalogSignals {
365            has_xmp_metadata: true,
366            output_intent_count: 1,
367            mark_info_marked: false,
368            has_struct_tree_root: false,
369            ..Default::default()
370        };
371        let xmp = XmpPacket {
372            pdfaid_part: Some(2),
373            pdfaid_conformance: Some("B".into()),
374            ..XmpPacket::default()
375        };
376        let c = PdfAConformance::from_signals_and_xmp(&sig, Some(&xmp));
377        assert!(c.is_declared());
378        // B-level needs no tagged-PDF — sound despite missing marked/struct.
379        assert!(c.structurally_sound);
380        assert!(!c.claim_inconsistent);
381    }
382
383    #[test]
384    fn case_insensitive_conformance_match() {
385        let sig = PdfACatalogSignals {
386            has_xmp_metadata: true,
387            output_intent_count: 1,
388            mark_info_marked: false,
389            has_struct_tree_root: false,
390            ..Default::default()
391        };
392        let xmp = XmpPacket {
393            pdfaid_part: Some(1),
394            // Some authoring tools emit lowercase.
395            pdfaid_conformance: Some("a".into()),
396            ..XmpPacket::default()
397        };
398        let c = PdfAConformance::from_signals_and_xmp(&sig, Some(&xmp));
399        assert!(c.claim_inconsistent);
400    }
401}