1use crate::error::PdfError;
26use crate::objects::{Dict, Object};
27use crate::reader::document::DocumentReader;
28use crate::reader::xmp::XmpPacket;
29
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct PdfACatalogSignals {
38 pub mark_info_marked: bool,
42 pub mark_info_user_properties: bool,
46 pub mark_info_suspects: bool,
49 pub has_struct_tree_root: bool,
52 pub catalog_lang: Option<String>,
55 pub output_intent_count: usize,
59 pub has_xmp_metadata: bool,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct PdfAConformance {
68 pub declared: Option<(u8, String)>,
72 pub structurally_sound: bool,
77 pub claim_inconsistent: bool,
81 pub inconsistencies: Vec<String>,
84}
85
86impl PdfAConformance {
87 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 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 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 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 }
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 pub fn is_declared(&self) -> bool {
157 self.declared.is_some()
158 }
159
160 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
172pub 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 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 sig.has_struct_tree_root = lookup(&catalog, "StructTreeRoot").is_some();
199
200 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 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 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 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 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}