Skip to main content

oxidize_pdf/pdfa/
validator.rs

1//! PDF/A Validation Engine
2//!
3//! This module provides the core validation logic for PDF/A compliance.
4//! It checks PDF documents against the requirements of PDF/A standards.
5
6use super::error::{PdfAError, ValidationError};
7use super::types::{PdfAConformance, PdfALevel, ValidationResult, ValidationWarning};
8use super::xmp::XmpMetadata;
9use crate::parser::{ParseOptions, PdfReader};
10use std::io::{Read, Seek};
11
12/// Extracted catalog data for validation
13struct CatalogData {
14    metadata_ref: Option<(u32, u16)>,
15    names_ref: Option<(u32, u16)>,
16    names_inline: Option<crate::parser::objects::PdfDictionary>,
17    open_action_ref: Option<(u32, u16)>,
18    open_action_inline: Option<crate::parser::objects::PdfDictionary>,
19    aa_ref: Option<(u32, u16)>,
20    aa_inline: Option<crate::parser::objects::PdfDictionary>,
21}
22
23/// PDF/A Validator
24///
25/// Validates PDF documents against PDF/A standards (ISO 19005).
26///
27/// # Example
28///
29/// ```rust,ignore
30/// use oxidize_pdf::parser::PdfReader;
31/// use oxidize_pdf::pdfa::{PdfAValidator, PdfALevel};
32///
33/// let mut reader = PdfReader::open("document.pdf")?;
34/// let validator = PdfAValidator::new(PdfALevel::A1b);
35/// let result = validator.validate(&mut reader)?;
36///
37/// if result.is_valid() {
38///     println!("Document is PDF/A-1b compliant!");
39/// } else {
40///     for error in result.errors() {
41///         println!("Violation: {}", error);
42///     }
43/// }
44/// ```
45#[derive(Debug, Clone)]
46pub struct PdfAValidator {
47    /// Target PDF/A level for validation
48    level: PdfALevel,
49    /// Whether to collect all errors or stop at first
50    collect_all_errors: bool,
51}
52
53impl PdfAValidator {
54    /// Create a new validator for the specified PDF/A level
55    pub fn new(level: PdfALevel) -> Self {
56        Self {
57            level,
58            collect_all_errors: true,
59        }
60    }
61
62    /// Set whether to collect all errors or stop at first error
63    pub fn collect_all_errors(mut self, collect: bool) -> Self {
64        self.collect_all_errors = collect;
65        self
66    }
67
68    /// Get the target PDF/A level
69    pub fn level(&self) -> PdfALevel {
70        self.level
71    }
72
73    /// Validate a PDF document against the configured PDF/A level
74    ///
75    /// Returns a `ValidationResult` containing all errors and warnings found.
76    pub fn validate<R: Read + Seek>(
77        &self,
78        reader: &mut PdfReader<R>,
79    ) -> Result<ValidationResult, PdfAError> {
80        let mut errors = Vec::new();
81        let mut warnings = Vec::new();
82
83        // Check encryption (forbidden in all PDF/A levels)
84        self.check_encryption(reader, &mut errors);
85
86        // Check PDF version compatibility
87        self.check_pdf_version(reader, &mut errors)?;
88
89        // Extract catalog data we need for validation before doing further operations
90        let catalog_data = self.extract_catalog_data(reader)?;
91
92        // Check XMP metadata (required in all PDF/A levels)
93        self.check_metadata_from_data(reader, &catalog_data, &mut errors, &mut warnings)?;
94
95        // Check for JavaScript (forbidden in all PDF/A levels)
96        self.check_javascript_from_data(reader, &catalog_data, &mut errors)?;
97
98        // Check external references (forbidden in all PDF/A levels)
99        self.check_external_references_from_data(reader, &catalog_data, &mut errors)?;
100
101        // Check transparency (forbidden in PDF/A-1, limited in PDF/A-2+)
102        if !self.level.allows_transparency() {
103            self.check_transparency(reader, &mut errors)?;
104        }
105
106        // Check compression (LZW forbidden in PDF/A-1)
107        if !self.level.allows_lzw() {
108            self.check_lzw_compression(reader, &mut errors)?;
109        }
110
111        // Check embedded files
112        self.check_embedded_files(reader, &catalog_data, &mut errors)?;
113
114        // Check fonts (must be embedded, Level A requires ToUnicode)
115        self.check_fonts(reader, &mut errors)?;
116
117        // Check color spaces and output intent
118        self.check_color_spaces(reader, &mut errors)?;
119
120        Ok(ValidationResult::with_errors_and_warnings(
121            self.level, errors, warnings,
122        ))
123    }
124
125    /// Check if PDF is encrypted (encryption is forbidden in PDF/A)
126    fn check_encryption<R: Read + Seek>(
127        &self,
128        reader: &PdfReader<R>,
129        errors: &mut Vec<ValidationError>,
130    ) {
131        if reader.is_encrypted() {
132            errors.push(ValidationError::EncryptionForbidden);
133        }
134    }
135
136    /// Check PDF version compatibility with the target PDF/A level
137    fn check_pdf_version<R: Read + Seek>(
138        &self,
139        reader: &PdfReader<R>,
140        errors: &mut Vec<ValidationError>,
141    ) -> Result<(), PdfAError> {
142        let version = reader.version();
143        let version_str = version.to_string();
144
145        let required = self.level.required_pdf_version();
146
147        // Parse versions for comparison
148        let actual_parts: Vec<u8> = version_str
149            .split('.')
150            .filter_map(|s| s.parse().ok())
151            .collect();
152
153        // Get major and minor versions
154        let (actual_major, actual_minor) = (
155            actual_parts.first().copied().unwrap_or(1),
156            actual_parts.get(1).copied().unwrap_or(0),
157        );
158
159        // For PDF/A-1, PDF version must be exactly 1.4
160        // For PDF/A-2 and PDF/A-3, PDF version must be 1.7 or compatible
161        let is_compatible = match self.level.part() {
162            1 => actual_major == 1 && actual_minor == 4,
163            2 | 3 => actual_major == 1 && actual_minor >= 4 && actual_minor <= 7,
164            _ => false,
165        };
166
167        if !is_compatible {
168            errors.push(ValidationError::IncompatiblePdfVersion {
169                actual: version_str,
170                required: required.to_string(),
171            });
172        }
173
174        Ok(())
175    }
176
177    /// Extract data from catalog that we need for validation
178    fn extract_catalog_data<R: Read + Seek>(
179        &self,
180        reader: &mut PdfReader<R>,
181    ) -> Result<CatalogData, PdfAError> {
182        let catalog = reader
183            .catalog()
184            .map_err(|e| PdfAError::ParseError(e.to_string()))?;
185
186        let metadata_ref = catalog
187            .get("Metadata")
188            .and_then(|obj| obj.as_reference())
189            .map(|(n, g)| (n, g));
190
191        let names_ref = catalog
192            .get("Names")
193            .and_then(|obj| obj.as_reference())
194            .map(|(n, g)| (n, g));
195
196        let names_inline = catalog.get("Names").and_then(|obj| obj.as_dict()).cloned();
197
198        let open_action_ref = catalog
199            .get("OpenAction")
200            .and_then(|obj| obj.as_reference())
201            .map(|(n, g)| (n, g));
202
203        let open_action_inline = catalog
204            .get("OpenAction")
205            .and_then(|obj| obj.as_dict())
206            .cloned();
207
208        let aa_ref = catalog
209            .get("AA")
210            .and_then(|obj| obj.as_reference())
211            .map(|(n, g)| (n, g));
212
213        let aa_inline = catalog.get("AA").and_then(|obj| obj.as_dict()).cloned();
214
215        Ok(CatalogData {
216            metadata_ref,
217            names_ref,
218            names_inline,
219            open_action_ref,
220            open_action_inline,
221            aa_ref,
222            aa_inline,
223        })
224    }
225
226    /// Check for XMP metadata using extracted catalog data
227    fn check_metadata_from_data<R: Read + Seek>(
228        &self,
229        reader: &mut PdfReader<R>,
230        catalog_data: &CatalogData,
231        errors: &mut Vec<ValidationError>,
232        _warnings: &mut Vec<ValidationWarning>,
233    ) -> Result<(), PdfAError> {
234        // Check for Metadata stream
235        let metadata_ref = match catalog_data.metadata_ref {
236            Some(r) => r,
237            None => {
238                errors.push(ValidationError::XmpMetadataMissing);
239                return Ok(());
240            }
241        };
242
243        // Resolve the reference
244        let obj = reader
245            .get_object(metadata_ref.0, metadata_ref.1)
246            .map_err(|e| PdfAError::ParseError(e.to_string()))?;
247
248        // Check if it's a stream
249        let stream = match obj.as_stream() {
250            Some(s) => s,
251            None => {
252                errors.push(ValidationError::XmpMetadataMissing);
253                return Ok(());
254            }
255        };
256
257        // Decode the stream before parsing: the /Metadata XMP packet may be
258        // filtered (commonly /FlateDecode), in which case `stream.data` holds
259        // the still-compressed bytes. Reading them raw makes `XmpMetadata::parse`
260        // fail (or yield no pdfa_id), spuriously reporting the document as
261        // non-conformant (issue #346). `decode` is a no-op for an unfiltered
262        // stream, so uncompressed metadata is unaffected.
263        let decoded = match stream.decode(&ParseOptions::default()) {
264            Ok(data) => data,
265            Err(_) => {
266                // The metadata stream exists but its filters cannot be applied,
267                // so the XMP packet is unreadable.
268                errors.push(ValidationError::XmpMetadataMissing);
269                return Ok(());
270            }
271        };
272
273        // Parse the XMP metadata
274        let xmp_data = String::from_utf8_lossy(&decoded);
275        let xmp = match XmpMetadata::parse(&xmp_data) {
276            Ok(x) => x,
277            Err(_) => {
278                errors.push(ValidationError::XmpMetadataMissing);
279                return Ok(());
280            }
281        };
282
283        // Check for PDF/A identifier
284        match &xmp.pdfa_id {
285            None => {
286                errors.push(ValidationError::XmpMissingPdfAIdentifier);
287            }
288            Some(pdfa_id) => {
289                // Validate the PDF/A identifier matches our target level
290                let expected_part = self.level.part();
291                let expected_conformance = self.level.conformance();
292
293                if pdfa_id.part != expected_part {
294                    errors.push(ValidationError::XmpInvalidPdfAIdentifier {
295                        details: format!(
296                            "Part mismatch: expected {}, found {}",
297                            expected_part, pdfa_id.part
298                        ),
299                    });
300                } else if pdfa_id.conformance != expected_conformance {
301                    errors.push(ValidationError::XmpInvalidPdfAIdentifier {
302                        details: format!(
303                            "Conformance mismatch: expected {:?}, found {:?}",
304                            expected_conformance, pdfa_id.conformance
305                        ),
306                    });
307                }
308            }
309        }
310
311        Ok(())
312    }
313
314    /// Check for JavaScript using extracted catalog data
315    fn check_javascript_from_data<R: Read + Seek>(
316        &self,
317        reader: &mut PdfReader<R>,
318        catalog_data: &CatalogData,
319        errors: &mut Vec<ValidationError>,
320    ) -> Result<(), PdfAError> {
321        // Check Names dictionary for JavaScript
322        if let Some((obj_num, gen_num)) = catalog_data.names_ref {
323            let names_obj = reader
324                .get_object(obj_num, gen_num)
325                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
326
327            if let Some(names_dict) = names_obj.as_dict() {
328                if names_dict.get("JavaScript").is_some() {
329                    errors.push(ValidationError::JavaScriptForbidden {
330                        location: "Names/JavaScript".to_string(),
331                    });
332                }
333            }
334        } else if let Some(ref names_dict) = catalog_data.names_inline {
335            if names_dict.get("JavaScript").is_some() {
336                errors.push(ValidationError::JavaScriptForbidden {
337                    location: "Names/JavaScript".to_string(),
338                });
339            }
340        }
341
342        // Check OpenAction for JavaScript
343        if let Some((obj_num, gen_num)) = catalog_data.open_action_ref {
344            let action_obj = reader
345                .get_object(obj_num, gen_num)
346                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
347
348            if let Some(action_dict) = action_obj.as_dict() {
349                if self.is_javascript_action(action_dict) {
350                    errors.push(ValidationError::JavaScriptForbidden {
351                        location: "OpenAction".to_string(),
352                    });
353                }
354            }
355        } else if let Some(ref action_dict) = catalog_data.open_action_inline {
356            if self.is_javascript_action(action_dict) {
357                errors.push(ValidationError::JavaScriptForbidden {
358                    location: "OpenAction".to_string(),
359                });
360            }
361        }
362
363        // Check AA (Additional Actions) dictionary
364        if let Some((obj_num, gen_num)) = catalog_data.aa_ref {
365            let aa_obj = reader
366                .get_object(obj_num, gen_num)
367                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
368
369            if let Some(aa_dict) = aa_obj.as_dict().cloned() {
370                if self.check_aa_dict_for_javascript(reader, &aa_dict)? {
371                    errors.push(ValidationError::JavaScriptForbidden {
372                        location: "Catalog/AA".to_string(),
373                    });
374                }
375            }
376        } else if let Some(ref aa_dict) = catalog_data.aa_inline {
377            if self.check_aa_dict_for_javascript(reader, aa_dict)? {
378                errors.push(ValidationError::JavaScriptForbidden {
379                    location: "Catalog/AA".to_string(),
380                });
381            }
382        }
383
384        Ok(())
385    }
386
387    /// Check if a dictionary is a JavaScript action
388    fn is_javascript_action(&self, dict: &crate::parser::objects::PdfDictionary) -> bool {
389        if let Some(action_type) = dict.get("S") {
390            if let Some(name) = action_type.as_name() {
391                return name.0 == "JavaScript";
392            }
393        }
394        false
395    }
396
397    /// Check AA dictionary for JavaScript actions
398    fn check_aa_dict_for_javascript<R: Read + Seek>(
399        &self,
400        reader: &mut PdfReader<R>,
401        aa_dict: &crate::parser::objects::PdfDictionary,
402    ) -> Result<bool, PdfAError> {
403        // Check each action in the AA dictionary
404        for (_key, value) in aa_dict.0.iter() {
405            let action_dict = if let Some((obj_num, gen_num)) = value.as_reference() {
406                let obj = reader
407                    .get_object(obj_num, gen_num)
408                    .map_err(|e| PdfAError::ParseError(e.to_string()))?;
409                obj.as_dict().cloned()
410            } else {
411                value.as_dict().cloned()
412            };
413
414            if let Some(dict) = action_dict {
415                if self.is_javascript_action(&dict) {
416                    return Ok(true);
417                }
418            }
419        }
420
421        Ok(false)
422    }
423
424    /// Check external references using extracted catalog data
425    fn check_external_references_from_data<R: Read + Seek>(
426        &self,
427        reader: &mut PdfReader<R>,
428        catalog_data: &CatalogData,
429        errors: &mut Vec<ValidationError>,
430    ) -> Result<(), PdfAError> {
431        // Check OpenAction for remote GoTo
432        if let Some((obj_num, gen_num)) = catalog_data.open_action_ref {
433            let action_obj = reader
434                .get_object(obj_num, gen_num)
435                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
436
437            if let Some(action_dict) = action_obj.as_dict() {
438                self.check_for_external_action(action_dict, errors);
439            }
440        } else if let Some(ref action_dict) = catalog_data.open_action_inline {
441            self.check_for_external_action(action_dict, errors);
442        }
443
444        Ok(())
445    }
446
447    /// Check if action is an external reference
448    fn check_for_external_action(
449        &self,
450        dict: &crate::parser::objects::PdfDictionary,
451        errors: &mut Vec<ValidationError>,
452    ) {
453        if let Some(action_type) = dict.get("S") {
454            if let Some(name) = action_type.as_name() {
455                if name.0 == "GoToR" || name.0 == "GoToE" || name.0 == "Launch" {
456                    errors.push(ValidationError::ExternalReferenceForbidden {
457                        reference_type: name.0.clone(),
458                    });
459                }
460            }
461        }
462    }
463
464    /// Check for transparency usage (forbidden in PDF/A-1)
465    fn check_transparency<R: Read + Seek>(
466        &self,
467        reader: &mut PdfReader<R>,
468        errors: &mut Vec<ValidationError>,
469    ) -> Result<(), PdfAError> {
470        let page_count = reader
471            .page_count()
472            .map_err(|e| PdfAError::ParseError(e.to_string()))?;
473
474        for page_idx in 0..page_count {
475            // Get page dictionary
476            let page_dict = self.get_page_dict(reader, page_idx)?;
477
478            // Check Resources for ExtGState with transparency
479            if let Some(resources) = self.get_resources_dict(reader, &page_dict)? {
480                // Check ExtGState entries
481                if let Some(ext_gstate) = resources.get("ExtGState") {
482                    self.check_ext_gstate_transparency(reader, ext_gstate, page_idx, errors)?;
483                }
484
485                // Check XObject entries for transparency groups
486                if let Some(xobjects) = resources.get("XObject") {
487                    self.check_xobject_transparency(reader, xobjects, page_idx, errors)?;
488                }
489            }
490        }
491
492        Ok(())
493    }
494
495    /// Get a page dictionary by index
496    fn get_page_dict<R: Read + Seek>(
497        &self,
498        reader: &mut PdfReader<R>,
499        page_idx: u32,
500    ) -> Result<crate::parser::objects::PdfDictionary, PdfAError> {
501        // Get pages dict
502        let pages_dict = reader
503            .pages()
504            .map_err(|e| PdfAError::ParseError(e.to_string()))?
505            .clone();
506
507        // Get Kids array
508        let kids = pages_dict
509            .get("Kids")
510            .and_then(|k| k.as_array())
511            .ok_or_else(|| PdfAError::ParseError("Pages missing Kids array".to_string()))?;
512
513        // Get page reference
514        let page_ref = kids
515            .0
516            .get(page_idx as usize)
517            .ok_or_else(|| PdfAError::ParseError(format!("Page {} not found", page_idx)))?;
518
519        // Resolve page reference
520        if let Some((obj_num, gen_num)) = page_ref.as_reference() {
521            let page_obj = reader
522                .get_object(obj_num, gen_num)
523                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
524            page_obj
525                .as_dict()
526                .cloned()
527                .ok_or_else(|| PdfAError::ParseError("Page is not a dictionary".to_string()))
528        } else if let Some(dict) = page_ref.as_dict() {
529            Ok(dict.clone())
530        } else {
531            Err(PdfAError::ParseError("Invalid page reference".to_string()))
532        }
533    }
534
535    /// Get Resources dictionary from page, resolving if needed
536    fn get_resources_dict<R: Read + Seek>(
537        &self,
538        reader: &mut PdfReader<R>,
539        page_dict: &crate::parser::objects::PdfDictionary,
540    ) -> Result<Option<crate::parser::objects::PdfDictionary>, PdfAError> {
541        let resources_obj = match page_dict.get("Resources") {
542            Some(obj) => obj,
543            None => return Ok(None),
544        };
545
546        if let Some((obj_num, gen_num)) = resources_obj.as_reference() {
547            let resolved = reader
548                .get_object(obj_num, gen_num)
549                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
550            Ok(resolved.as_dict().cloned())
551        } else {
552            Ok(resources_obj.as_dict().cloned())
553        }
554    }
555
556    /// Check ExtGState dictionary for transparency settings
557    fn check_ext_gstate_transparency<R: Read + Seek>(
558        &self,
559        reader: &mut PdfReader<R>,
560        ext_gstate_obj: &crate::parser::objects::PdfObject,
561        page_idx: u32,
562        errors: &mut Vec<ValidationError>,
563    ) -> Result<(), PdfAError> {
564        let ext_gstate_dict = if let Some((obj_num, gen_num)) = ext_gstate_obj.as_reference() {
565            let obj = reader
566                .get_object(obj_num, gen_num)
567                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
568            obj.as_dict().cloned()
569        } else {
570            ext_gstate_obj.as_dict().cloned()
571        };
572
573        let ext_gstate_dict = match ext_gstate_dict {
574            Some(d) => d,
575            None => return Ok(()),
576        };
577
578        // Check each graphics state entry
579        for (gs_name, gs_value) in ext_gstate_dict.0.iter() {
580            let gs_dict = if let Some((obj_num, gen_num)) = gs_value.as_reference() {
581                let obj = reader
582                    .get_object(obj_num, gen_num)
583                    .map_err(|e| PdfAError::ParseError(e.to_string()))?;
584                obj.as_dict().cloned()
585            } else {
586                gs_value.as_dict().cloned()
587            };
588
589            if let Some(gs_dict) = gs_dict {
590                // Check for CA (stroke alpha) != 1.0
591                if let Some(ca) = gs_dict.get("CA") {
592                    let val = ca.as_real().or_else(|| ca.as_integer().map(|i| i as f64));
593                    if let Some(val) = val {
594                        if (val - 1.0).abs() > f64::EPSILON {
595                            errors.push(ValidationError::TransparencyForbidden {
596                                location: format!(
597                                    "Page {}, ExtGState/{}/CA",
598                                    page_idx + 1,
599                                    &gs_name.0
600                                ),
601                            });
602                        }
603                    }
604                }
605
606                // Check for ca (fill alpha) != 1.0
607                if let Some(ca) = gs_dict.get("ca") {
608                    let val = ca.as_real().or_else(|| ca.as_integer().map(|i| i as f64));
609                    if let Some(val) = val {
610                        if (val - 1.0).abs() > f64::EPSILON {
611                            errors.push(ValidationError::TransparencyForbidden {
612                                location: format!(
613                                    "Page {}, ExtGState/{}/ca",
614                                    page_idx + 1,
615                                    &gs_name.0
616                                ),
617                            });
618                        }
619                    }
620                }
621
622                // Check for SMask
623                if let Some(smask) = gs_dict.get("SMask") {
624                    // SMask /None is allowed
625                    let is_none = smask.as_name().map(|n| n.0 == "None").unwrap_or(false);
626                    if !is_none {
627                        errors.push(ValidationError::TransparencyForbidden {
628                            location: format!(
629                                "Page {}, ExtGState/{}/SMask",
630                                page_idx + 1,
631                                &gs_name.0
632                            ),
633                        });
634                    }
635                }
636
637                // Check for BM (blend mode) != Normal
638                if let Some(bm) = gs_dict.get("BM") {
639                    if let Some(name) = bm.as_name() {
640                        if name.0 != "Normal" && name.0 != "Compatible" {
641                            errors.push(ValidationError::TransparencyForbidden {
642                                location: format!(
643                                    "Page {}, ExtGState/{}/BM={}",
644                                    page_idx + 1,
645                                    &gs_name.0,
646                                    &name.0
647                                ),
648                            });
649                        }
650                    }
651                }
652            }
653        }
654
655        Ok(())
656    }
657
658    /// Check XObject dictionary for transparency groups
659    fn check_xobject_transparency<R: Read + Seek>(
660        &self,
661        reader: &mut PdfReader<R>,
662        xobject_obj: &crate::parser::objects::PdfObject,
663        page_idx: u32,
664        errors: &mut Vec<ValidationError>,
665    ) -> Result<(), PdfAError> {
666        let xobject_dict = if let Some((obj_num, gen_num)) = xobject_obj.as_reference() {
667            let obj = reader
668                .get_object(obj_num, gen_num)
669                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
670            obj.as_dict().cloned()
671        } else {
672            xobject_obj.as_dict().cloned()
673        };
674
675        let xobject_dict = match xobject_dict {
676            Some(d) => d,
677            None => return Ok(()),
678        };
679
680        // Check each XObject entry
681        for (xo_name, xo_value) in xobject_dict.0.iter() {
682            let xo_stream_dict = if let Some((obj_num, gen_num)) = xo_value.as_reference() {
683                let obj = reader
684                    .get_object(obj_num, gen_num)
685                    .map_err(|e| PdfAError::ParseError(e.to_string()))?;
686                if let Some(stream) = obj.as_stream() {
687                    Some(stream.dict.clone())
688                } else {
689                    obj.as_dict().cloned()
690                }
691            } else if let Some(stream) = xo_value.as_stream() {
692                Some(stream.dict.clone())
693            } else {
694                xo_value.as_dict().cloned()
695            };
696
697            if let Some(xo_dict) = xo_stream_dict {
698                // Check for transparency group (/Group with /S /Transparency)
699                if let Some(group) = xo_dict.get("Group") {
700                    let group_dict = if let Some((obj_num, gen_num)) = group.as_reference() {
701                        let obj = reader
702                            .get_object(obj_num, gen_num)
703                            .map_err(|e| PdfAError::ParseError(e.to_string()))?;
704                        obj.as_dict().cloned()
705                    } else {
706                        group.as_dict().cloned()
707                    };
708
709                    if let Some(group_dict) = group_dict {
710                        if let Some(s) = group_dict.get("S") {
711                            if let Some(name) = s.as_name() {
712                                if name.0 == "Transparency" {
713                                    errors.push(ValidationError::TransparencyForbidden {
714                                        location: format!(
715                                            "Page {}, XObject/{} has transparency group",
716                                            page_idx + 1,
717                                            &xo_name.0
718                                        ),
719                                    });
720                                }
721                            }
722                        }
723                    }
724                }
725
726                // Check for SMask in Image XObjects
727                if let Some(subtype) = xo_dict.get("Subtype") {
728                    if let Some(name) = subtype.as_name() {
729                        if name.0 == "Image" {
730                            if xo_dict.get("SMask").is_some() {
731                                errors.push(ValidationError::TransparencyForbidden {
732                                    location: format!(
733                                        "Page {}, Image XObject/{} has SMask",
734                                        page_idx + 1,
735                                        &xo_name.0
736                                    ),
737                                });
738                            }
739                        }
740                    }
741                }
742            }
743        }
744
745        Ok(())
746    }
747
748    /// Check for LZW compression (forbidden in PDF/A-1)
749    ///
750    /// Note: This performs a sample check on page resources. A full implementation
751    /// would scan all streams in the document.
752    fn check_lzw_compression<R: Read + Seek>(
753        &self,
754        reader: &mut PdfReader<R>,
755        errors: &mut Vec<ValidationError>,
756    ) -> Result<(), PdfAError> {
757        let page_count = reader
758            .page_count()
759            .map_err(|e| PdfAError::ParseError(e.to_string()))?;
760
761        for page_idx in 0..page_count {
762            let page_dict = self.get_page_dict(reader, page_idx)?;
763
764            // Check Resources for XObjects that might use LZW
765            if let Some(resources) = self.get_resources_dict(reader, &page_dict)? {
766                if let Some(xobjects) = resources.get("XObject") {
767                    self.check_xobjects_for_lzw(reader, xobjects, page_idx, errors)?;
768                }
769            }
770
771            // Check content stream(s)
772            if let Some(contents) = page_dict.get("Contents") {
773                self.check_contents_for_lzw(reader, contents, page_idx, errors)?;
774            }
775        }
776
777        Ok(())
778    }
779
780    /// Check XObjects for LZW compression
781    fn check_xobjects_for_lzw<R: Read + Seek>(
782        &self,
783        reader: &mut PdfReader<R>,
784        xobject_obj: &crate::parser::objects::PdfObject,
785        page_idx: u32,
786        errors: &mut Vec<ValidationError>,
787    ) -> Result<(), PdfAError> {
788        let xobject_dict = if let Some((obj_num, gen_num)) = xobject_obj.as_reference() {
789            let obj = reader
790                .get_object(obj_num, gen_num)
791                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
792            obj.as_dict().cloned()
793        } else {
794            xobject_obj.as_dict().cloned()
795        };
796
797        let xobject_dict = match xobject_dict {
798            Some(d) => d,
799            None => return Ok(()),
800        };
801
802        for (_xo_name, xo_value) in xobject_dict.0.iter() {
803            if let Some((obj_num, gen_num)) = xo_value.as_reference() {
804                if let Ok(obj) = reader.get_object(obj_num, gen_num) {
805                    if let Some(stream) = obj.as_stream() {
806                        self.check_stream_for_lzw(&stream.dict, obj_num, page_idx, errors);
807                    }
808                }
809            }
810        }
811
812        Ok(())
813    }
814
815    /// Check content streams for LZW compression
816    fn check_contents_for_lzw<R: Read + Seek>(
817        &self,
818        reader: &mut PdfReader<R>,
819        contents: &crate::parser::objects::PdfObject,
820        page_idx: u32,
821        errors: &mut Vec<ValidationError>,
822    ) -> Result<(), PdfAError> {
823        // Contents can be a reference or an array of references
824        if let Some((obj_num, gen_num)) = contents.as_reference() {
825            if let Ok(obj) = reader.get_object(obj_num, gen_num) {
826                if let Some(stream) = obj.as_stream() {
827                    self.check_stream_for_lzw(&stream.dict, obj_num, page_idx, errors);
828                }
829            }
830        } else if let Some(arr) = contents.as_array() {
831            for item in &arr.0 {
832                if let Some((obj_num, gen_num)) = item.as_reference() {
833                    if let Ok(obj) = reader.get_object(obj_num, gen_num) {
834                        if let Some(stream) = obj.as_stream() {
835                            self.check_stream_for_lzw(&stream.dict, obj_num, page_idx, errors);
836                        }
837                    }
838                }
839            }
840        }
841
842        Ok(())
843    }
844
845    /// Check a stream dictionary for LZW filter
846    fn check_stream_for_lzw(
847        &self,
848        dict: &crate::parser::objects::PdfDictionary,
849        obj_num: u32,
850        page_idx: u32,
851        errors: &mut Vec<ValidationError>,
852    ) {
853        if let Some(filter) = dict.get("Filter") {
854            // Filter can be a name or array
855            if let Some(name) = filter.as_name() {
856                if name.0 == "LZWDecode" {
857                    errors.push(ValidationError::LzwCompressionForbidden {
858                        object_id: format!("page {}, object {} 0", page_idx + 1, obj_num),
859                    });
860                }
861            } else if let Some(arr) = filter.as_array() {
862                for (idx, f) in arr.0.iter().enumerate() {
863                    if let Some(name) = f.as_name() {
864                        if name.0 == "LZWDecode" {
865                            errors.push(ValidationError::LzwCompressionForbidden {
866                                object_id: format!(
867                                    "page {}, object {} 0 (filter {})",
868                                    page_idx + 1,
869                                    obj_num,
870                                    idx
871                                ),
872                            });
873                        }
874                    }
875                }
876            }
877        }
878    }
879
880    /// Check for embedded files (forbidden in PDF/A-1 and PDF/A-2)
881    fn check_embedded_files<R: Read + Seek>(
882        &self,
883        reader: &mut PdfReader<R>,
884        catalog_data: &CatalogData,
885        errors: &mut Vec<ValidationError>,
886    ) -> Result<(), PdfAError> {
887        if self.level.allows_embedded_files() {
888            // PDF/A-3 allows embedded files, but they must have proper metadata
889            // Full validation would check AF entries
890            return Ok(());
891        }
892
893        // Check Names/EmbeddedFiles
894        if let Some((obj_num, gen_num)) = catalog_data.names_ref {
895            let names_obj = reader
896                .get_object(obj_num, gen_num)
897                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
898
899            if let Some(names_dict) = names_obj.as_dict() {
900                if names_dict.get("EmbeddedFiles").is_some() {
901                    errors.push(ValidationError::EmbeddedFileForbidden);
902                }
903            }
904        } else if let Some(ref names_dict) = catalog_data.names_inline {
905            if names_dict.get("EmbeddedFiles").is_some() {
906                errors.push(ValidationError::EmbeddedFileForbidden);
907            }
908        }
909
910        Ok(())
911    }
912
913    /// Check that all fonts are properly embedded
914    fn check_fonts<R: Read + Seek>(
915        &self,
916        reader: &mut PdfReader<R>,
917        errors: &mut Vec<ValidationError>,
918    ) -> Result<(), PdfAError> {
919        let page_count = reader
920            .page_count()
921            .map_err(|e| PdfAError::ParseError(e.to_string()))?;
922        let requires_tounicode = self.level.conformance() == PdfAConformance::A;
923
924        for page_idx in 0..page_count {
925            let page_dict = self.get_page_dict(reader, page_idx)?;
926
927            if let Some(resources) = self.get_resources_dict(reader, &page_dict)? {
928                if let Some(fonts_obj) = resources.get("Font") {
929                    self.check_font_resources(reader, fonts_obj, requires_tounicode, errors)?;
930                }
931            }
932        }
933
934        Ok(())
935    }
936
937    /// Check font resources dictionary
938    fn check_font_resources<R: Read + Seek>(
939        &self,
940        reader: &mut PdfReader<R>,
941        fonts_obj: &crate::parser::objects::PdfObject,
942        requires_tounicode: bool,
943        errors: &mut Vec<ValidationError>,
944    ) -> Result<(), PdfAError> {
945        let fonts_dict = if let Some((obj_num, gen_num)) = fonts_obj.as_reference() {
946            let obj = reader
947                .get_object(obj_num, gen_num)
948                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
949            obj.as_dict().cloned()
950        } else {
951            fonts_obj.as_dict().cloned()
952        };
953
954        let fonts_dict = match fonts_dict {
955            Some(d) => d,
956            None => return Ok(()),
957        };
958
959        // Check each font
960        for (font_name, font_ref) in fonts_dict.0.iter() {
961            let font_dict = if let Some((obj_num, gen_num)) = font_ref.as_reference() {
962                let obj = reader
963                    .get_object(obj_num, gen_num)
964                    .map_err(|e| PdfAError::ParseError(e.to_string()))?;
965                obj.as_dict().cloned()
966            } else {
967                font_ref.as_dict().cloned()
968            };
969
970            if let Some(font_dict) = font_dict {
971                self.check_single_font(
972                    reader,
973                    &font_name.0,
974                    &font_dict,
975                    requires_tounicode,
976                    errors,
977                )?;
978            }
979        }
980
981        Ok(())
982    }
983
984    /// Check a single font for PDF/A compliance
985    fn check_single_font<R: Read + Seek>(
986        &self,
987        reader: &mut PdfReader<R>,
988        font_name: &str,
989        font_dict: &crate::parser::objects::PdfDictionary,
990        requires_tounicode: bool,
991        errors: &mut Vec<ValidationError>,
992    ) -> Result<(), PdfAError> {
993        // Get font type
994        let font_type = font_dict
995            .get("Subtype")
996            .and_then(|s| s.as_name())
997            .map(|n| n.0.clone())
998            .unwrap_or_default();
999
1000        // Type3 fonts have different requirements
1001        if font_type == "Type3" {
1002            // Type3 fonts are always considered "embedded" as they define glyphs inline
1003            // But for Level A, they still need character mapping
1004            if requires_tounicode && font_dict.get("ToUnicode").is_none() {
1005                errors.push(ValidationError::FontMissingToUnicode {
1006                    font_name: font_name.to_string(),
1007                });
1008            }
1009            return Ok(());
1010        }
1011
1012        // For Type0 (composite) fonts, check the descendant font
1013        if font_type == "Type0" {
1014            return self.check_type0_font(reader, font_name, font_dict, requires_tounicode, errors);
1015        }
1016
1017        // Check FontDescriptor for embedding (Type1, TrueType, etc.)
1018        let font_descriptor = self.get_font_descriptor(reader, font_dict)?;
1019
1020        if let Some(desc) = font_descriptor {
1021            // Check for font embedding: FontFile, FontFile2, or FontFile3
1022            let has_fontfile = desc.get("FontFile").is_some()
1023                || desc.get("FontFile2").is_some()
1024                || desc.get("FontFile3").is_some();
1025
1026            if !has_fontfile {
1027                errors.push(ValidationError::FontNotEmbedded {
1028                    font_name: font_name.to_string(),
1029                });
1030            }
1031        } else {
1032            // No FontDescriptor means the font is not embedded
1033            // Exception: standard 14 fonts technically don't need FontDescriptor
1034            // but PDF/A still requires them to be embedded
1035            errors.push(ValidationError::FontNotEmbedded {
1036                font_name: font_name.to_string(),
1037            });
1038        }
1039
1040        // For Level A conformance, check ToUnicode
1041        if requires_tounicode && font_dict.get("ToUnicode").is_none() {
1042            // Check if font has proper encoding that allows Unicode mapping
1043            let has_encoding = font_dict.get("Encoding").is_some();
1044            if !has_encoding {
1045                errors.push(ValidationError::FontMissingToUnicode {
1046                    font_name: font_name.to_string(),
1047                });
1048            }
1049        }
1050
1051        Ok(())
1052    }
1053
1054    /// Check Type0 (composite) font for embedding
1055    fn check_type0_font<R: Read + Seek>(
1056        &self,
1057        reader: &mut PdfReader<R>,
1058        font_name: &str,
1059        font_dict: &crate::parser::objects::PdfDictionary,
1060        requires_tounicode: bool,
1061        errors: &mut Vec<ValidationError>,
1062    ) -> Result<(), PdfAError> {
1063        // Get DescendantFonts array
1064        let descendants = match font_dict.get("DescendantFonts") {
1065            Some(d) => d,
1066            None => {
1067                errors.push(ValidationError::FontNotEmbedded {
1068                    font_name: font_name.to_string(),
1069                });
1070                return Ok(());
1071            }
1072        };
1073
1074        let desc_array = if let Some((obj_num, gen_num)) = descendants.as_reference() {
1075            let obj = reader
1076                .get_object(obj_num, gen_num)
1077                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
1078            obj.as_array().cloned()
1079        } else {
1080            descendants.as_array().cloned()
1081        };
1082
1083        let desc_array = match desc_array {
1084            Some(a) => a,
1085            None => return Ok(()),
1086        };
1087
1088        // Check first descendant font (CIDFont)
1089        if let Some(cid_font_ref) = desc_array.0.first() {
1090            let cid_font_dict = if let Some((obj_num, gen_num)) = cid_font_ref.as_reference() {
1091                let obj = reader
1092                    .get_object(obj_num, gen_num)
1093                    .map_err(|e| PdfAError::ParseError(e.to_string()))?;
1094                obj.as_dict().cloned()
1095            } else {
1096                cid_font_ref.as_dict().cloned()
1097            };
1098
1099            if let Some(cid_dict) = cid_font_dict {
1100                // Check CIDFont's FontDescriptor
1101                let font_descriptor = self.get_font_descriptor(reader, &cid_dict)?;
1102
1103                if let Some(desc) = font_descriptor {
1104                    let has_fontfile = desc.get("FontFile").is_some()
1105                        || desc.get("FontFile2").is_some()
1106                        || desc.get("FontFile3").is_some();
1107
1108                    if !has_fontfile {
1109                        errors.push(ValidationError::FontNotEmbedded {
1110                            font_name: font_name.to_string(),
1111                        });
1112                    }
1113                } else {
1114                    errors.push(ValidationError::FontNotEmbedded {
1115                        font_name: font_name.to_string(),
1116                    });
1117                }
1118            }
1119        }
1120
1121        // For Level A, check ToUnicode or CMap
1122        if requires_tounicode && font_dict.get("ToUnicode").is_none() {
1123            // Type0 fonts might use Identity-H/V encoding which is acceptable
1124            // if combined with proper CIDToGIDMap
1125            let encoding = font_dict.get("Encoding").and_then(|e| e.as_name());
1126            let is_identity = encoding
1127                .map(|n| n.0 == "Identity-H" || n.0 == "Identity-V")
1128                .unwrap_or(false);
1129
1130            if !is_identity {
1131                errors.push(ValidationError::FontMissingToUnicode {
1132                    font_name: font_name.to_string(),
1133                });
1134            }
1135        }
1136
1137        Ok(())
1138    }
1139
1140    /// Get FontDescriptor from a font dictionary
1141    fn get_font_descriptor<R: Read + Seek>(
1142        &self,
1143        reader: &mut PdfReader<R>,
1144        font_dict: &crate::parser::objects::PdfDictionary,
1145    ) -> Result<Option<crate::parser::objects::PdfDictionary>, PdfAError> {
1146        let desc_ref = match font_dict.get("FontDescriptor") {
1147            Some(d) => d,
1148            None => return Ok(None),
1149        };
1150
1151        if let Some((obj_num, gen_num)) = desc_ref.as_reference() {
1152            let obj = reader
1153                .get_object(obj_num, gen_num)
1154                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
1155            Ok(obj.as_dict().cloned())
1156        } else {
1157            Ok(desc_ref.as_dict().cloned())
1158        }
1159    }
1160
1161    /// Check color spaces for PDF/A compliance
1162    ///
1163    /// PDF/A requires device-independent color spaces or a properly defined
1164    /// OutputIntent. Device-dependent color spaces (DeviceRGB, DeviceCMYK,
1165    /// DeviceGray) are only allowed if an OutputIntent is present.
1166    fn check_color_spaces<R: Read + Seek>(
1167        &self,
1168        reader: &mut PdfReader<R>,
1169        errors: &mut Vec<ValidationError>,
1170    ) -> Result<(), PdfAError> {
1171        // First, check if there's an OutputIntent in the catalog
1172        let has_output_intent = self.has_output_intent(reader)?;
1173
1174        let page_count = reader
1175            .page_count()
1176            .map_err(|e| PdfAError::ParseError(e.to_string()))?;
1177
1178        for page_idx in 0..page_count {
1179            let page_dict = self.get_page_dict(reader, page_idx)?;
1180
1181            if let Some(resources) = self.get_resources_dict(reader, &page_dict)? {
1182                // Check ColorSpace dictionary
1183                if let Some(cs_obj) = resources.get("ColorSpace") {
1184                    self.check_colorspace_dict(
1185                        reader,
1186                        cs_obj,
1187                        page_idx,
1188                        has_output_intent,
1189                        errors,
1190                    )?;
1191                }
1192
1193                // Check XObjects for uncalibrated color spaces in images
1194                if let Some(xobjects) = resources.get("XObject") {
1195                    self.check_xobject_colorspaces(
1196                        reader,
1197                        xobjects,
1198                        page_idx,
1199                        has_output_intent,
1200                        errors,
1201                    )?;
1202                }
1203            }
1204        }
1205
1206        Ok(())
1207    }
1208
1209    /// Check if the document has a valid OutputIntent
1210    fn has_output_intent<R: Read + Seek>(
1211        &self,
1212        reader: &mut PdfReader<R>,
1213    ) -> Result<bool, PdfAError> {
1214        let catalog = reader
1215            .catalog()
1216            .map_err(|e| PdfAError::ParseError(e.to_string()))?;
1217
1218        if let Some(output_intents) = catalog.get("OutputIntents") {
1219            let arr = if let Some((obj_num, gen_num)) = output_intents.as_reference() {
1220                let obj = reader
1221                    .get_object(obj_num, gen_num)
1222                    .map_err(|e| PdfAError::ParseError(e.to_string()))?;
1223                obj.as_array().cloned()
1224            } else {
1225                output_intents.as_array().cloned()
1226            };
1227
1228            if let Some(arr) = arr {
1229                // Check if any OutputIntent has the required PDF/A subtype
1230                for item in &arr.0 {
1231                    let intent_dict = if let Some((obj_num, gen_num)) = item.as_reference() {
1232                        let obj = reader
1233                            .get_object(obj_num, gen_num)
1234                            .map_err(|e| PdfAError::ParseError(e.to_string()))?;
1235                        obj.as_dict().cloned()
1236                    } else {
1237                        item.as_dict().cloned()
1238                    };
1239
1240                    if let Some(dict) = intent_dict {
1241                        // Check for GTS_PDFA1 or similar subtype
1242                        if let Some(subtype) = dict.get("S") {
1243                            if let Some(name) = subtype.as_name() {
1244                                if name.0.contains("PDFA") || name.0.contains("PDF/A") {
1245                                    return Ok(true);
1246                                }
1247                            }
1248                        }
1249                        // Also check for DestOutputProfile
1250                        if dict.get("DestOutputProfile").is_some() {
1251                            return Ok(true);
1252                        }
1253                    }
1254                }
1255            }
1256        }
1257
1258        Ok(false)
1259    }
1260
1261    /// Check ColorSpace dictionary entries
1262    fn check_colorspace_dict<R: Read + Seek>(
1263        &self,
1264        reader: &mut PdfReader<R>,
1265        cs_obj: &crate::parser::objects::PdfObject,
1266        page_idx: u32,
1267        has_output_intent: bool,
1268        errors: &mut Vec<ValidationError>,
1269    ) -> Result<(), PdfAError> {
1270        let cs_dict = if let Some((obj_num, gen_num)) = cs_obj.as_reference() {
1271            let obj = reader
1272                .get_object(obj_num, gen_num)
1273                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
1274            obj.as_dict().cloned()
1275        } else {
1276            cs_obj.as_dict().cloned()
1277        };
1278
1279        let cs_dict = match cs_dict {
1280            Some(d) => d,
1281            None => return Ok(()),
1282        };
1283
1284        for (cs_name, cs_value) in cs_dict.0.iter() {
1285            self.validate_colorspace(
1286                reader,
1287                &cs_name.0,
1288                cs_value,
1289                page_idx,
1290                has_output_intent,
1291                errors,
1292            )?;
1293        }
1294
1295        Ok(())
1296    }
1297
1298    /// Validate a single color space entry
1299    fn validate_colorspace<R: Read + Seek>(
1300        &self,
1301        reader: &mut PdfReader<R>,
1302        cs_name: &str,
1303        cs_value: &crate::parser::objects::PdfObject,
1304        page_idx: u32,
1305        has_output_intent: bool,
1306        errors: &mut Vec<ValidationError>,
1307    ) -> Result<(), PdfAError> {
1308        // Color space can be a name or an array
1309        let cs_type = if let Some(name) = cs_value.as_name() {
1310            name.0.clone()
1311        } else if let Some(arr) = cs_value.as_array() {
1312            // First element is the color space name
1313            arr.0
1314                .first()
1315                .and_then(|o| o.as_name())
1316                .map(|n| n.0.clone())
1317                .unwrap_or_default()
1318        } else if let Some((obj_num, gen_num)) = cs_value.as_reference() {
1319            // Resolve reference
1320            let obj = reader
1321                .get_object(obj_num, gen_num)
1322                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
1323            if let Some(name) = obj.as_name() {
1324                name.0.clone()
1325            } else if let Some(arr) = obj.as_array() {
1326                arr.0
1327                    .first()
1328                    .and_then(|o| o.as_name())
1329                    .map(|n| n.0.clone())
1330                    .unwrap_or_default()
1331            } else {
1332                return Ok(());
1333            }
1334        } else {
1335            return Ok(());
1336        };
1337
1338        // Check if it's a device-dependent color space
1339        if self.is_device_dependent_colorspace(&cs_type) && !has_output_intent {
1340            errors.push(ValidationError::InvalidColorSpace {
1341                color_space: cs_type,
1342                location: format!("Page {}, ColorSpace/{}", page_idx + 1, cs_name),
1343            });
1344        }
1345
1346        Ok(())
1347    }
1348
1349    /// Check XObjects for device-dependent color spaces
1350    fn check_xobject_colorspaces<R: Read + Seek>(
1351        &self,
1352        reader: &mut PdfReader<R>,
1353        xobjects_obj: &crate::parser::objects::PdfObject,
1354        page_idx: u32,
1355        has_output_intent: bool,
1356        errors: &mut Vec<ValidationError>,
1357    ) -> Result<(), PdfAError> {
1358        let xobjects_dict = if let Some((obj_num, gen_num)) = xobjects_obj.as_reference() {
1359            let obj = reader
1360                .get_object(obj_num, gen_num)
1361                .map_err(|e| PdfAError::ParseError(e.to_string()))?;
1362            obj.as_dict().cloned()
1363        } else {
1364            xobjects_obj.as_dict().cloned()
1365        };
1366
1367        let xobjects_dict = match xobjects_dict {
1368            Some(d) => d,
1369            None => return Ok(()),
1370        };
1371
1372        for (xo_name, xo_ref) in xobjects_dict.0.iter() {
1373            let xo_dict = if let Some((obj_num, gen_num)) = xo_ref.as_reference() {
1374                let obj = reader
1375                    .get_object(obj_num, gen_num)
1376                    .map_err(|e| PdfAError::ParseError(e.to_string()))?;
1377                if let Some(stream) = obj.as_stream() {
1378                    Some(stream.dict.clone())
1379                } else {
1380                    obj.as_dict().cloned()
1381                }
1382            } else if let Some(stream) = xo_ref.as_stream() {
1383                Some(stream.dict.clone())
1384            } else {
1385                xo_ref.as_dict().cloned()
1386            };
1387
1388            if let Some(dict) = xo_dict {
1389                // Check if it's an Image XObject
1390                let is_image = dict
1391                    .get("Subtype")
1392                    .and_then(|s| s.as_name())
1393                    .map(|n| n.0 == "Image")
1394                    .unwrap_or(false);
1395
1396                if is_image {
1397                    // Check ColorSpace of the image
1398                    if let Some(cs) = dict.get("ColorSpace") {
1399                        let cs_type = if let Some(name) = cs.as_name() {
1400                            name.0.clone()
1401                        } else if let Some(arr) = cs.as_array() {
1402                            arr.0
1403                                .first()
1404                                .and_then(|o| o.as_name())
1405                                .map(|n| n.0.clone())
1406                                .unwrap_or_default()
1407                        } else {
1408                            String::new()
1409                        };
1410
1411                        if self.is_device_dependent_colorspace(&cs_type) && !has_output_intent {
1412                            errors.push(ValidationError::InvalidColorSpace {
1413                                color_space: cs_type,
1414                                location: format!("Page {}, XObject/{}", page_idx + 1, &xo_name.0),
1415                            });
1416                        }
1417                    }
1418                }
1419            }
1420        }
1421
1422        Ok(())
1423    }
1424
1425    /// Check if a color space is device-dependent
1426    fn is_device_dependent_colorspace(&self, cs_type: &str) -> bool {
1427        matches!(cs_type, "DeviceRGB" | "DeviceCMYK" | "DeviceGray")
1428    }
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433    use super::*;
1434
1435    #[test]
1436    fn test_validator_new() {
1437        let validator = PdfAValidator::new(PdfALevel::A1b);
1438        assert_eq!(validator.level(), PdfALevel::A1b);
1439    }
1440
1441    #[test]
1442    fn test_validator_level_a2b() {
1443        let validator = PdfAValidator::new(PdfALevel::A2b);
1444        assert_eq!(validator.level(), PdfALevel::A2b);
1445    }
1446
1447    #[test]
1448    fn test_validator_level_a3b() {
1449        let validator = PdfAValidator::new(PdfALevel::A3b);
1450        assert_eq!(validator.level(), PdfALevel::A3b);
1451    }
1452
1453    #[test]
1454    fn test_validator_collect_all_errors() {
1455        let validator = PdfAValidator::new(PdfALevel::A1b).collect_all_errors(false);
1456        assert!(!validator.collect_all_errors);
1457    }
1458
1459    #[test]
1460    fn test_validator_clone() {
1461        let validator = PdfAValidator::new(PdfALevel::A2u);
1462        let cloned = validator.clone();
1463        assert_eq!(cloned.level(), PdfALevel::A2u);
1464    }
1465
1466    #[test]
1467    fn test_validator_debug() {
1468        let validator = PdfAValidator::new(PdfALevel::A1a);
1469        let debug_str = format!("{:?}", validator);
1470        assert!(debug_str.contains("PdfAValidator"));
1471        assert!(debug_str.contains("A1a"));
1472    }
1473
1474    #[test]
1475    fn test_is_javascript_action_true() {
1476        use crate::parser::objects::{PdfDictionary, PdfName, PdfObject};
1477
1478        let validator = PdfAValidator::new(PdfALevel::A1b);
1479        let mut dict = PdfDictionary::new();
1480        dict.insert(
1481            "S".to_string(),
1482            PdfObject::Name(PdfName("JavaScript".to_string())),
1483        );
1484
1485        assert!(validator.is_javascript_action(&dict));
1486    }
1487
1488    #[test]
1489    fn test_is_javascript_action_false() {
1490        use crate::parser::objects::{PdfDictionary, PdfName, PdfObject};
1491
1492        let validator = PdfAValidator::new(PdfALevel::A1b);
1493        let mut dict = PdfDictionary::new();
1494        dict.insert(
1495            "S".to_string(),
1496            PdfObject::Name(PdfName("GoTo".to_string())),
1497        );
1498
1499        assert!(!validator.is_javascript_action(&dict));
1500    }
1501
1502    #[test]
1503    fn test_is_javascript_action_no_s_key() {
1504        use crate::parser::objects::PdfDictionary;
1505
1506        let validator = PdfAValidator::new(PdfALevel::A1b);
1507        let dict = PdfDictionary::new();
1508
1509        assert!(!validator.is_javascript_action(&dict));
1510    }
1511
1512    #[test]
1513    fn test_check_for_external_action_gotor() {
1514        use crate::parser::objects::{PdfDictionary, PdfName, PdfObject};
1515
1516        let validator = PdfAValidator::new(PdfALevel::A1b);
1517        let mut dict = PdfDictionary::new();
1518        dict.insert(
1519            "S".to_string(),
1520            PdfObject::Name(PdfName("GoToR".to_string())),
1521        );
1522
1523        let mut errors = Vec::new();
1524        validator.check_for_external_action(&dict, &mut errors);
1525
1526        assert_eq!(errors.len(), 1);
1527        assert!(matches!(
1528            errors[0],
1529            ValidationError::ExternalReferenceForbidden { .. }
1530        ));
1531    }
1532
1533    #[test]
1534    fn test_check_for_external_action_launch() {
1535        use crate::parser::objects::{PdfDictionary, PdfName, PdfObject};
1536
1537        let validator = PdfAValidator::new(PdfALevel::A1b);
1538        let mut dict = PdfDictionary::new();
1539        dict.insert(
1540            "S".to_string(),
1541            PdfObject::Name(PdfName("Launch".to_string())),
1542        );
1543
1544        let mut errors = Vec::new();
1545        validator.check_for_external_action(&dict, &mut errors);
1546
1547        assert_eq!(errors.len(), 1);
1548    }
1549
1550    #[test]
1551    fn test_check_for_external_action_goto_internal() {
1552        use crate::parser::objects::{PdfDictionary, PdfName, PdfObject};
1553
1554        let validator = PdfAValidator::new(PdfALevel::A1b);
1555        let mut dict = PdfDictionary::new();
1556        dict.insert(
1557            "S".to_string(),
1558            PdfObject::Name(PdfName("GoTo".to_string())),
1559        );
1560
1561        let mut errors = Vec::new();
1562        validator.check_for_external_action(&dict, &mut errors);
1563
1564        assert_eq!(errors.len(), 0); // Internal GoTo is allowed
1565    }
1566
1567    #[test]
1568    fn test_check_stream_for_lzw_single_filter() {
1569        use crate::parser::objects::{PdfDictionary, PdfName, PdfObject};
1570
1571        let validator = PdfAValidator::new(PdfALevel::A1b);
1572        let mut dict = PdfDictionary::new();
1573        dict.insert(
1574            "Filter".to_string(),
1575            PdfObject::Name(PdfName("LZWDecode".to_string())),
1576        );
1577
1578        let mut errors = Vec::new();
1579        validator.check_stream_for_lzw(&dict, 10, 0, &mut errors);
1580
1581        assert_eq!(errors.len(), 1);
1582        assert!(matches!(
1583            &errors[0],
1584            ValidationError::LzwCompressionForbidden { object_id } if object_id == "page 1, object 10 0"
1585        ));
1586    }
1587
1588    #[test]
1589    fn test_check_stream_for_lzw_array_filter() {
1590        use crate::parser::objects::{PdfArray, PdfDictionary, PdfName, PdfObject};
1591
1592        let validator = PdfAValidator::new(PdfALevel::A1b);
1593        let mut dict = PdfDictionary::new();
1594        let filters = PdfArray(vec![
1595            PdfObject::Name(PdfName("FlateDecode".to_string())),
1596            PdfObject::Name(PdfName("LZWDecode".to_string())),
1597        ]);
1598        dict.insert("Filter".to_string(), PdfObject::Array(filters));
1599
1600        let mut errors = Vec::new();
1601        validator.check_stream_for_lzw(&dict, 20, 2, &mut errors);
1602
1603        assert_eq!(errors.len(), 1);
1604        assert!(matches!(
1605            &errors[0],
1606            ValidationError::LzwCompressionForbidden { object_id } if object_id.contains("page 3") && object_id.contains("object 20 0")
1607        ));
1608    }
1609
1610    #[test]
1611    fn test_check_stream_for_lzw_no_lzw() {
1612        use crate::parser::objects::{PdfDictionary, PdfName, PdfObject};
1613
1614        let validator = PdfAValidator::new(PdfALevel::A1b);
1615        let mut dict = PdfDictionary::new();
1616        dict.insert(
1617            "Filter".to_string(),
1618            PdfObject::Name(PdfName("FlateDecode".to_string())),
1619        );
1620
1621        let mut errors = Vec::new();
1622        validator.check_stream_for_lzw(&dict, 30, 0, &mut errors);
1623
1624        assert_eq!(errors.len(), 0);
1625    }
1626
1627    #[test]
1628    fn test_check_stream_for_lzw_no_filter() {
1629        use crate::parser::objects::PdfDictionary;
1630
1631        let validator = PdfAValidator::new(PdfALevel::A1b);
1632        let dict = PdfDictionary::new();
1633
1634        let mut errors = Vec::new();
1635        validator.check_stream_for_lzw(&dict, 40, 0, &mut errors);
1636
1637        assert_eq!(errors.len(), 0);
1638    }
1639
1640    #[test]
1641    fn test_pdfa_level_allows_lzw() {
1642        // PDF/A-1 does not allow LZW
1643        assert!(!PdfALevel::A1b.allows_lzw());
1644        assert!(!PdfALevel::A1a.allows_lzw());
1645
1646        // PDF/A-2 and PDF/A-3 allow LZW
1647        assert!(PdfALevel::A2b.allows_lzw());
1648        assert!(PdfALevel::A3b.allows_lzw());
1649    }
1650
1651    #[test]
1652    fn test_pdfa_level_allows_embedded_files() {
1653        // PDF/A-1 and PDF/A-2 do not allow embedded files
1654        assert!(!PdfALevel::A1b.allows_embedded_files());
1655        assert!(!PdfALevel::A2b.allows_embedded_files());
1656
1657        // PDF/A-3 allows embedded files
1658        assert!(PdfALevel::A3b.allows_embedded_files());
1659    }
1660
1661    #[test]
1662    fn test_is_device_dependent_colorspace() {
1663        let validator = PdfAValidator::new(PdfALevel::A1b);
1664
1665        // Device-dependent color spaces
1666        assert!(validator.is_device_dependent_colorspace("DeviceRGB"));
1667        assert!(validator.is_device_dependent_colorspace("DeviceCMYK"));
1668        assert!(validator.is_device_dependent_colorspace("DeviceGray"));
1669
1670        // Device-independent color spaces
1671        assert!(!validator.is_device_dependent_colorspace("CalRGB"));
1672        assert!(!validator.is_device_dependent_colorspace("CalGray"));
1673        assert!(!validator.is_device_dependent_colorspace("Lab"));
1674        assert!(!validator.is_device_dependent_colorspace("ICCBased"));
1675        assert!(!validator.is_device_dependent_colorspace("Indexed"));
1676        assert!(!validator.is_device_dependent_colorspace("Pattern"));
1677    }
1678
1679    #[test]
1680    fn test_pdfa_conformance_level_a() {
1681        // Level A requires ToUnicode for accessible conformance
1682        assert_eq!(PdfALevel::A1a.conformance(), PdfAConformance::A);
1683        assert_eq!(PdfALevel::A2a.conformance(), PdfAConformance::A);
1684        assert_eq!(PdfALevel::A3a.conformance(), PdfAConformance::A);
1685
1686        // Level B is basic conformance
1687        assert_eq!(PdfALevel::A1b.conformance(), PdfAConformance::B);
1688        assert_eq!(PdfALevel::A2b.conformance(), PdfAConformance::B);
1689        assert_eq!(PdfALevel::A3b.conformance(), PdfAConformance::B);
1690
1691        // Level U is Unicode conformance
1692        assert_eq!(PdfALevel::A2u.conformance(), PdfAConformance::U);
1693        assert_eq!(PdfALevel::A3u.conformance(), PdfAConformance::U);
1694    }
1695}