Skip to main content

pdf_ast/validation/
constraints.rs

1use super::*;
2use crate::ast::{AstNode, NodeType, PdfDocument};
3use crate::types::{PdfDictionary, PdfStream, PdfValue};
4
5fn resolve_node_from_value<'a>(document: &'a PdfDocument, value: &PdfValue) -> Option<&'a AstNode> {
6    match value {
7        PdfValue::Reference(reference) => document.ast.get_node_by_object(reference.id()),
8        _ => None,
9    }
10}
11
12fn resolve_dict_from_value(document: &PdfDocument, value: &PdfValue) -> Option<PdfDictionary> {
13    match value {
14        PdfValue::Dictionary(dict) => Some(dict.clone()),
15        PdfValue::Stream(stream) => Some(stream.dict.clone()),
16        PdfValue::Reference(_) => resolve_node_from_value(document, value).and_then(|node| {
17            node.as_dict()
18                .cloned()
19                .or_else(|| node.as_stream().map(|s| s.dict.clone()))
20        }),
21        _ => None,
22    }
23}
24
25fn resolve_stream_from_value(document: &PdfDocument, value: &PdfValue) -> Option<PdfStream> {
26    match value {
27        PdfValue::Stream(stream) => Some(stream.clone()),
28        PdfValue::Reference(_) => {
29            resolve_node_from_value(document, value).and_then(|node| node.as_stream().cloned())
30        }
31        _ => None,
32    }
33}
34
35fn value_contains_name(value: &PdfValue, name: &str) -> bool {
36    match value {
37        PdfValue::Name(n) => n.without_slash() == name || n.as_str() == name,
38        PdfValue::Array(arr) => arr.iter().any(|v| value_contains_name(v, name)),
39        PdfValue::Dictionary(dict) => dict.values().any(|v| value_contains_name(v, name)),
40        PdfValue::Stream(stream) => stream.dict.values().any(|v| value_contains_name(v, name)),
41        _ => false,
42    }
43}
44
45/// Basic constraint: Document must have a catalog
46pub struct HasCatalogConstraint;
47
48impl SchemaConstraint for HasCatalogConstraint {
49    fn name(&self) -> &str {
50        "has-catalog"
51    }
52
53    fn description(&self) -> &str {
54        "Document must have a catalog dictionary"
55    }
56
57    fn category(&self) -> ConstraintCategory {
58        ConstraintCategory::Structure
59    }
60
61    fn iso_reference(&self) -> Option<&str> {
62        Some("ISO 32000-2:2020 Catalog dictionary")
63    }
64
65    fn required_node_types(&self) -> Vec<NodeType> {
66        vec![NodeType::Catalog]
67    }
68
69    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
70        let catalog_nodes = document.ast.find_nodes_by_type(NodeType::Catalog);
71
72        if catalog_nodes.is_empty() {
73            report.add_issue(ValidationIssue {
74                severity: ValidationSeverity::Critical,
75                code: "CATALOG_MISSING".to_string(),
76                message: "Document must contain a catalog dictionary".to_string(),
77                node_id: None,
78                location: Some("Document root".to_string()),
79                suggestion: Some("Add a catalog dictionary to the document".to_string()),
80            });
81        } else if catalog_nodes.len() > 1 {
82            report.add_issue(ValidationIssue {
83                severity: ValidationSeverity::Error,
84                code: "MULTIPLE_CATALOGS".to_string(),
85                message: "Document contains multiple catalog dictionaries".to_string(),
86                node_id: Some(NodeId::new(catalog_nodes[1].index())),
87                location: Some("Document structure".to_string()),
88                suggestion: Some("Remove duplicate catalog dictionaries".to_string()),
89            });
90        } else {
91            report.add_passed_check();
92        }
93    }
94}
95
96/// Constraint: Trailer must have Root entry
97pub struct HasTrailerRootConstraint;
98
99impl SchemaConstraint for HasTrailerRootConstraint {
100    fn name(&self) -> &str {
101        "has-trailer-root"
102    }
103
104    fn description(&self) -> &str {
105        "Trailer must contain /Root"
106    }
107
108    fn category(&self) -> ConstraintCategory {
109        ConstraintCategory::Structure
110    }
111
112    fn iso_reference(&self) -> Option<&str> {
113        Some("ISO 32000-2:2020 File trailer")
114    }
115
116    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
117        if document.trailer.contains_key("Root") {
118            report.add_passed_check();
119        } else {
120            report.add_issue(ValidationIssue {
121                severity: ValidationSeverity::Error,
122                code: "TRAILER_ROOT_MISSING".to_string(),
123                message: "Trailer dictionary missing /Root".to_string(),
124                node_id: None,
125                location: Some("Trailer".to_string()),
126                suggestion: Some("Add /Root entry in trailer".to_string()),
127            });
128        }
129    }
130}
131
132/// Constraint: Trailer must have Size entry
133pub struct HasTrailerSizeConstraint;
134
135impl SchemaConstraint for HasTrailerSizeConstraint {
136    fn name(&self) -> &str {
137        "has-trailer-size"
138    }
139
140    fn description(&self) -> &str {
141        "Trailer must contain /Size"
142    }
143
144    fn category(&self) -> ConstraintCategory {
145        ConstraintCategory::Structure
146    }
147
148    fn iso_reference(&self) -> Option<&str> {
149        Some("ISO 32000-2:2020 File trailer")
150    }
151
152    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
153        if let Some(size) = document.trailer.get("Size").and_then(|v| v.as_integer()) {
154            if size > 0 {
155                report.add_passed_check();
156                return;
157            }
158        }
159        report.add_issue(ValidationIssue {
160            severity: ValidationSeverity::Error,
161            code: "TRAILER_SIZE_MISSING".to_string(),
162            message: "Trailer dictionary missing /Size or size <= 0".to_string(),
163            node_id: None,
164            location: Some("Trailer".to_string()),
165            suggestion: Some("Add /Size entry in trailer".to_string()),
166        });
167    }
168}
169
170/// Constraint: PDF 2.0 should declare /Version 2.0
171pub struct CatalogVersionConstraint;
172
173impl SchemaConstraint for CatalogVersionConstraint {
174    fn name(&self) -> &str {
175        "catalog-version"
176    }
177
178    fn description(&self) -> &str {
179        "Catalog /Version should be 2.0 when validating against PDF 2.0"
180    }
181
182    fn category(&self) -> ConstraintCategory {
183        ConstraintCategory::Structure
184    }
185
186    fn iso_reference(&self) -> Option<&str> {
187        Some("ISO 32000-2:2020 Header and catalog version")
188    }
189
190    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
191        let mut has_version = false;
192        let mut is_2_0 = false;
193
194        if let Some(catalog_id) = document.catalog {
195            if let Some(node) = document.ast.get_node(catalog_id) {
196                if let PdfValue::Dictionary(dict) = &node.value {
197                    if let Some(version_value) = dict.get("Version") {
198                        has_version = true;
199                        match version_value {
200                            PdfValue::Name(name) => {
201                                let v = name.without_slash();
202                                if v == "2.0" {
203                                    is_2_0 = true;
204                                }
205                            }
206                            PdfValue::String(s) => {
207                                if s.to_string_lossy() == "2.0" {
208                                    is_2_0 = true;
209                                }
210                            }
211                            _ => {}
212                        }
213                    }
214                }
215            }
216        }
217
218        if document.version.major >= 2 {
219            if has_version && is_2_0 {
220                report.add_passed_check();
221            } else {
222                report.add_issue(ValidationIssue {
223                    severity: ValidationSeverity::Warning,
224                    code: "CATALOG_VERSION_MISSING".to_string(),
225                    message: "Catalog /Version 2.0 not declared".to_string(),
226                    node_id: document.catalog,
227                    location: Some("Catalog".to_string()),
228                    suggestion: Some("Add /Version 2.0 to catalog".to_string()),
229                });
230            }
231        } else if has_version && is_2_0 {
232            report.add_issue(ValidationIssue {
233                severity: ValidationSeverity::Warning,
234                code: "CATALOG_VERSION_MISMATCH".to_string(),
235                message: "Catalog /Version 2.0 but header is older".to_string(),
236                node_id: document.catalog,
237                location: Some("Catalog".to_string()),
238                suggestion: Some("Align header and /Version".to_string()),
239            });
240        } else {
241            report.add_passed_check();
242        }
243    }
244}
245
246/// Constraint: XRef table must contain entries
247pub struct HasXRefEntriesConstraint;
248
249impl SchemaConstraint for HasXRefEntriesConstraint {
250    fn name(&self) -> &str {
251        "has-xref-entries"
252    }
253
254    fn description(&self) -> &str {
255        "Document must have at least one xref entry"
256    }
257
258    fn category(&self) -> ConstraintCategory {
259        ConstraintCategory::Structure
260    }
261
262    fn iso_reference(&self) -> Option<&str> {
263        Some("ISO 32000-2:2020 Cross-reference table")
264    }
265
266    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
267        if document.xref.entries.is_empty() {
268            report.add_issue(ValidationIssue {
269                severity: ValidationSeverity::Error,
270                code: "XREF_MISSING".to_string(),
271                message: "Cross-reference table has no entries".to_string(),
272                node_id: None,
273                location: Some("XRef".to_string()),
274                suggestion: Some("Add xref entries or recover objects".to_string()),
275            });
276        } else {
277            report.add_passed_check();
278        }
279    }
280}
281
282/// Constraint: Trailer /Size must align with max object number
283pub struct TrailerSizeConsistencyConstraint;
284
285impl SchemaConstraint for TrailerSizeConsistencyConstraint {
286    fn name(&self) -> &str {
287        "trailer-size-consistency"
288    }
289
290    fn description(&self) -> &str {
291        "Trailer /Size should be >= max object number + 1"
292    }
293
294    fn category(&self) -> ConstraintCategory {
295        ConstraintCategory::Structure
296    }
297
298    fn iso_reference(&self) -> Option<&str> {
299        Some("ISO 32000-2:2020 Cross-reference table and trailer")
300    }
301
302    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
303        let max_obj = document
304            .xref
305            .entries
306            .keys()
307            .map(|id| id.number as i64)
308            .max()
309            .unwrap_or(-1);
310
311        if let Some(size) = document.trailer.get("Size").and_then(|v| v.as_integer()) {
312            let expected_min = max_obj + 1;
313            if size < expected_min {
314                report.add_issue(ValidationIssue {
315                    severity: ValidationSeverity::Warning,
316                    code: "TRAILER_SIZE_INCONSISTENT".to_string(),
317                    message: format!("Trailer /Size {} is less than max object {}", size, max_obj),
318                    node_id: None,
319                    location: Some("Trailer".to_string()),
320                    suggestion: Some("Update /Size to match objects".to_string()),
321                });
322            } else {
323                report.add_passed_check();
324            }
325        } else {
326            report.add_issue(ValidationIssue {
327                severity: ValidationSeverity::Warning,
328                code: "TRAILER_SIZE_MISSING".to_string(),
329                message: "Trailer /Size missing for consistency check".to_string(),
330                node_id: None,
331                location: Some("Trailer".to_string()),
332                suggestion: Some("Add /Size to trailer".to_string()),
333            });
334        }
335    }
336}
337
338/// Constraint: Document must have a pages tree
339pub struct HasPagesTreeConstraint;
340
341impl SchemaConstraint for HasPagesTreeConstraint {
342    fn name(&self) -> &str {
343        "has-pages-tree"
344    }
345
346    fn description(&self) -> &str {
347        "Document must have a pages tree"
348    }
349
350    fn category(&self) -> ConstraintCategory {
351        ConstraintCategory::Structure
352    }
353
354    fn iso_reference(&self) -> Option<&str> {
355        Some("ISO 32000-2:2020 Page tree")
356    }
357
358    fn required_node_types(&self) -> Vec<NodeType> {
359        vec![NodeType::Pages]
360    }
361
362    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
363        let pages_nodes = document.ast.find_nodes_by_type(NodeType::Pages);
364
365        if pages_nodes.is_empty() {
366            report.add_issue(ValidationIssue {
367                severity: ValidationSeverity::Critical,
368                code: "PAGES_TREE_MISSING".to_string(),
369                message: "Document must contain a pages tree".to_string(),
370                node_id: None,
371                location: Some("Document structure".to_string()),
372                suggestion: Some("Add a pages tree to the document".to_string()),
373            });
374        } else {
375            // Check that pages tree has correct structure
376            let pages_node_id = pages_nodes[0];
377            if let Some(pages_node) = document.ast.get_node(pages_node_id) {
378                if let PdfValue::Dictionary(dict) = &pages_node.value {
379                    if !dict.contains_key("Kids") {
380                        report.add_issue(ValidationIssue {
381                            severity: ValidationSeverity::Error,
382                            code: "PAGES_NO_KIDS".to_string(),
383                            message: "Pages tree must contain Kids array".to_string(),
384                            node_id: Some(pages_node_id),
385                            location: Some("Pages dictionary".to_string()),
386                            suggestion: Some("Add Kids array to pages dictionary".to_string()),
387                        });
388                    }
389
390                    if !dict.contains_key("Count") {
391                        report.add_issue(ValidationIssue {
392                            severity: ValidationSeverity::Error,
393                            code: "PAGES_NO_COUNT".to_string(),
394                            message: "Pages tree must contain Count entry".to_string(),
395                            node_id: Some(pages_node_id),
396                            location: Some("Pages dictionary".to_string()),
397                            suggestion: Some("Add Count entry to pages dictionary".to_string()),
398                        });
399                    } else {
400                        report.add_passed_check();
401                    }
402                }
403            }
404        }
405    }
406}
407
408/// Constraint: Catalog must include Pages reference
409pub struct CatalogHasPagesConstraint;
410
411impl SchemaConstraint for CatalogHasPagesConstraint {
412    fn name(&self) -> &str {
413        "catalog-has-pages"
414    }
415
416    fn description(&self) -> &str {
417        "Catalog must contain /Pages reference"
418    }
419
420    fn category(&self) -> ConstraintCategory {
421        ConstraintCategory::Structure
422    }
423
424    fn iso_reference(&self) -> Option<&str> {
425        Some("ISO 32000-2:2020 Catalog and pages tree")
426    }
427
428    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
429        if let Some(catalog_id) = document.catalog {
430            if let Some(catalog_node) = document.ast.get_node(catalog_id) {
431                if let PdfValue::Dictionary(dict) = &catalog_node.value {
432                    if dict.contains_key("Pages") {
433                        report.add_passed_check();
434                        return;
435                    }
436                }
437            }
438        }
439
440        report.add_issue(ValidationIssue {
441            severity: ValidationSeverity::Error,
442            code: "CATALOG_PAGES_MISSING".to_string(),
443            message: "Catalog missing /Pages entry".to_string(),
444            node_id: document.catalog,
445            location: Some("Catalog".to_string()),
446            suggestion: Some("Add /Pages reference to catalog".to_string()),
447        });
448    }
449}
450
451/// Constraint: Pages /Count should match actual page nodes
452pub struct PageCountConsistencyConstraint;
453
454impl SchemaConstraint for PageCountConsistencyConstraint {
455    fn name(&self) -> &str {
456        "pages-count-consistency"
457    }
458
459    fn description(&self) -> &str {
460        "Pages /Count should match number of Page nodes"
461    }
462
463    fn category(&self) -> ConstraintCategory {
464        ConstraintCategory::Structure
465    }
466
467    fn iso_reference(&self) -> Option<&str> {
468        Some("ISO 32000-2:2020 Page tree count")
469    }
470
471    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
472        let page_nodes = document.ast.find_nodes_by_type(NodeType::Page);
473        let actual = page_nodes.len() as i64;
474        let mut reported = None;
475
476        if let Some(pages_id) = document
477            .ast
478            .find_nodes_by_type(NodeType::Pages)
479            .first()
480            .copied()
481        {
482            if let Some(pages_node) = document.ast.get_node(pages_id) {
483                if let PdfValue::Dictionary(dict) = &pages_node.value {
484                    if let Some(count) = dict.get("Count").and_then(|v| v.as_integer()) {
485                        reported = Some(count);
486                    }
487                }
488            }
489        }
490
491        if let Some(count) = reported {
492            if count != actual {
493                report.add_issue(ValidationIssue {
494                    severity: ValidationSeverity::Warning,
495                    code: "PAGES_COUNT_MISMATCH".to_string(),
496                    message: format!(
497                        "Pages /Count {} does not match actual pages {}",
498                        count, actual
499                    ),
500                    node_id: document.catalog,
501                    location: Some("Pages tree".to_string()),
502                    suggestion: Some("Update /Count to match page nodes".to_string()),
503                });
504            } else {
505                report.add_passed_check();
506            }
507        } else {
508            report.add_issue(ValidationIssue {
509                severity: ValidationSeverity::Warning,
510                code: "PAGES_COUNT_MISSING".to_string(),
511                message: "Pages /Count missing for consistency check".to_string(),
512                node_id: document.catalog,
513                location: Some("Pages tree".to_string()),
514                suggestion: Some("Add /Count to pages tree".to_string()),
515            });
516        }
517    }
518}
519
520/// Constraint: Trailer /ID must be an array of two strings
521pub struct TrailerIdConstraint;
522
523impl SchemaConstraint for TrailerIdConstraint {
524    fn name(&self) -> &str {
525        "trailer-id"
526    }
527
528    fn description(&self) -> &str {
529        "Trailer /ID should be array of two strings"
530    }
531
532    fn category(&self) -> ConstraintCategory {
533        ConstraintCategory::Structure
534    }
535
536    fn iso_reference(&self) -> Option<&str> {
537        Some("ISO 32000-2:2020 File identifiers")
538    }
539
540    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
541        if let Some(value) = document.trailer.get("ID") {
542            if let PdfValue::Array(arr) = value {
543                if arr.len() == 2 && arr.iter().all(|v| matches!(v, PdfValue::String(_))) {
544                    report.add_passed_check();
545                    return;
546                }
547            }
548
549            report.add_issue(ValidationIssue {
550                severity: ValidationSeverity::Warning,
551                code: "TRAILER_ID_INVALID".to_string(),
552                message: "Trailer /ID is malformed".to_string(),
553                node_id: None,
554                location: Some("Trailer".to_string()),
555                suggestion: Some("Set /ID to array of two strings".to_string()),
556            });
557        } else {
558            report.add_issue(ValidationIssue {
559                severity: ValidationSeverity::Warning,
560                code: "TRAILER_ID_MISSING".to_string(),
561                message: "Trailer /ID missing".to_string(),
562                node_id: None,
563                location: Some("Trailer".to_string()),
564                suggestion: Some("Add /ID to trailer".to_string()),
565            });
566        }
567    }
568}
569
570/// Constraint: Metadata stream must be /Subtype /XML
571pub struct MetadataStreamConstraint;
572
573impl SchemaConstraint for MetadataStreamConstraint {
574    fn name(&self) -> &str {
575        "metadata-stream"
576    }
577
578    fn description(&self) -> &str {
579        "Metadata stream must be XML"
580    }
581
582    fn category(&self) -> ConstraintCategory {
583        ConstraintCategory::Metadata
584    }
585
586    fn iso_reference(&self) -> Option<&str> {
587        Some("ISO 32000-2:2020 Metadata streams")
588    }
589
590    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
591        let catalog_id = match document.catalog {
592            Some(id) => id,
593            None => return,
594        };
595
596        let catalog = match document.ast.get_node(catalog_id) {
597            Some(node) => node,
598            None => return,
599        };
600
601        if let PdfValue::Dictionary(dict) = &catalog.value {
602            if let Some(metadata) = dict.get("Metadata") {
603                if let Some(stream) = resolve_stream_from_value(document, metadata) {
604                    if let Some(PdfValue::Name(subtype)) = stream.dict.get("Subtype") {
605                        if subtype.without_slash() == "XML" {
606                            report.add_passed_check();
607                            return;
608                        }
609                    }
610                    report.add_issue(ValidationIssue {
611                        severity: ValidationSeverity::Warning,
612                        code: "METADATA_SUBTYPE_INVALID".to_string(),
613                        message: "Metadata stream is not /Subtype /XML".to_string(),
614                        node_id: Some(catalog_id),
615                        location: Some("Metadata".to_string()),
616                        suggestion: Some("Set metadata stream /Subtype to /XML".to_string()),
617                    });
618                } else {
619                    report.add_issue(ValidationIssue {
620                        severity: ValidationSeverity::Warning,
621                        code: "METADATA_NOT_STREAM".to_string(),
622                        message: "Metadata entry is not a stream".to_string(),
623                        node_id: Some(catalog_id),
624                        location: Some("Metadata".to_string()),
625                        suggestion: Some("Use XMP metadata stream".to_string()),
626                    });
627                }
628            }
629        }
630    }
631}
632
633/// Constraint: No encryption allowed
634pub struct NoEncryptionConstraint;
635
636impl SchemaConstraint for NoEncryptionConstraint {
637    fn name(&self) -> &str {
638        "no-encryption"
639    }
640
641    fn description(&self) -> &str {
642        "Document must not be encrypted"
643    }
644
645    fn category(&self) -> ConstraintCategory {
646        ConstraintCategory::Security
647    }
648
649    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
650        if document.metadata.encrypted {
651            report.add_issue(ValidationIssue {
652                severity: ValidationSeverity::Error,
653                code: "ENCRYPTION_NOT_ALLOWED".to_string(),
654                message: "Document encryption is not allowed in this profile".to_string(),
655                node_id: None,
656                location: Some("Document trailer".to_string()),
657                suggestion: Some("Remove encryption from the document".to_string()),
658            });
659        } else {
660            report.add_passed_check();
661        }
662    }
663}
664
665/// Constraint: No JavaScript allowed
666pub struct NoJavaScriptConstraint;
667
668impl SchemaConstraint for NoJavaScriptConstraint {
669    fn name(&self) -> &str {
670        "no-javascript"
671    }
672
673    fn description(&self) -> &str {
674        "Document must not contain JavaScript"
675    }
676
677    fn category(&self) -> ConstraintCategory {
678        ConstraintCategory::JavaScript
679    }
680
681    fn required_node_types(&self) -> Vec<NodeType> {
682        vec![NodeType::JavaScriptAction, NodeType::EmbeddedJS]
683    }
684
685    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
686        let js_nodes = document.ast.find_nodes_by_type(NodeType::JavaScriptAction);
687        let embedded_js_nodes = document.ast.find_nodes_by_type(NodeType::EmbeddedJS);
688
689        if !js_nodes.is_empty() || !embedded_js_nodes.is_empty() {
690            for node in js_nodes.iter().chain(embedded_js_nodes.iter()) {
691                report.add_issue(ValidationIssue {
692                    severity: ValidationSeverity::Error,
693                    code: "JAVASCRIPT_NOT_ALLOWED".to_string(),
694                    message: "JavaScript is not allowed in this profile".to_string(),
695                    node_id: Some(*node),
696                    location: Some("JavaScript action or embedded script".to_string()),
697                    suggestion: Some("Remove JavaScript code from the document".to_string()),
698                });
699            }
700        } else {
701            report.add_passed_check();
702        }
703    }
704}
705
706/// Constraint: No external references allowed
707pub struct NoExternalReferencesConstraint;
708
709impl SchemaConstraint for NoExternalReferencesConstraint {
710    fn name(&self) -> &str {
711        "no-external-references"
712    }
713
714    fn description(&self) -> &str {
715        "Document must not contain external references"
716    }
717
718    fn category(&self) -> ConstraintCategory {
719        ConstraintCategory::Security
720    }
721
722    fn required_node_types(&self) -> Vec<NodeType> {
723        vec![NodeType::ExternalReference, NodeType::URIAction]
724    }
725
726    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
727        let external_refs = document.ast.find_nodes_by_type(NodeType::ExternalReference);
728        let uri_actions = document.ast.find_nodes_by_type(NodeType::URIAction);
729
730        for node in external_refs.iter().chain(uri_actions.iter()) {
731            report.add_issue(ValidationIssue {
732                severity: ValidationSeverity::Error,
733                code: "EXTERNAL_REFERENCE_NOT_ALLOWED".to_string(),
734                message: "External references are not allowed in this profile".to_string(),
735                node_id: Some(*node),
736                location: Some("External reference or URI action".to_string()),
737                suggestion: Some("Remove external references from the document".to_string()),
738            });
739        }
740
741        if external_refs.is_empty() && uri_actions.is_empty() {
742            report.add_passed_check();
743        }
744    }
745}
746
747/// Constraint: All fonts must be embedded
748pub struct EmbeddedFontsConstraint;
749
750impl SchemaConstraint for EmbeddedFontsConstraint {
751    fn name(&self) -> &str {
752        "embedded-fonts"
753    }
754
755    fn description(&self) -> &str {
756        "All fonts must be embedded in the document"
757    }
758
759    fn category(&self) -> ConstraintCategory {
760        ConstraintCategory::Fonts
761    }
762
763    fn required_node_types(&self) -> Vec<NodeType> {
764        vec![
765            NodeType::Font,
766            NodeType::Type1Font,
767            NodeType::TrueTypeFont,
768            NodeType::Type3Font,
769            NodeType::CIDFont,
770        ]
771    }
772
773    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
774        let font_types = vec![
775            NodeType::Font,
776            NodeType::Type1Font,
777            NodeType::TrueTypeFont,
778            NodeType::Type3Font,
779            NodeType::CIDFont,
780        ];
781        let mut all_embedded = true;
782
783        for font_type in font_types {
784            let fonts = document.ast.find_nodes_by_type(font_type);
785
786            for font_id in fonts {
787                if let Some(font) = document.ast.get_node(font_id) {
788                    if let PdfValue::Dictionary(dict) = &font.value {
789                        // Check if font has FontFile, FontFile2, FontFile3, or CIDFontFile
790                        let has_font_file = dict.contains_key("FontFile")
791                            || dict.contains_key("FontFile2")
792                            || dict.contains_key("FontFile3")
793                            || dict.contains_key("CIDFontFile");
794
795                        if !has_font_file {
796                            all_embedded = false;
797                            report.add_issue(ValidationIssue {
798                                severity: ValidationSeverity::Error,
799                                code: "FONT_NOT_EMBEDDED".to_string(),
800                                message: "Font is not embedded in the document".to_string(),
801                                node_id: Some(font_id),
802                                location: Some("Font dictionary".to_string()),
803                                suggestion: Some("Embed the font in the document".to_string()),
804                            });
805                        }
806                    }
807                }
808            }
809        }
810
811        if all_embedded {
812            report.add_passed_check();
813        }
814    }
815}
816
817/// Constraint: Document must have tagged structure
818pub struct TaggedStructureConstraint;
819
820impl SchemaConstraint for TaggedStructureConstraint {
821    fn name(&self) -> &str {
822        "tagged-structure"
823    }
824
825    fn description(&self) -> &str {
826        "Document must have tagged structure for accessibility"
827    }
828
829    fn category(&self) -> ConstraintCategory {
830        ConstraintCategory::Accessibility
831    }
832
833    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
834        let catalog_nodes = document.ast.find_nodes_by_type(NodeType::Catalog);
835
836        if let Some(catalog_id) = catalog_nodes.first() {
837            if let Some(catalog) = document.ast.get_node(*catalog_id) {
838                if let PdfValue::Dictionary(dict) = &catalog.value {
839                    if let Some(PdfValue::Dictionary(mark_info)) = dict.get("MarkInfo") {
840                        if let Some(PdfValue::Boolean(marked)) = mark_info.get("Marked") {
841                            if *marked {
842                                // Check for StructTreeRoot
843                                if dict.contains_key("StructTreeRoot") {
844                                    report.add_passed_check();
845                                    return;
846                                }
847                            }
848                        }
849                    }
850                }
851            }
852        }
853
854        report.add_issue(ValidationIssue {
855            severity: ValidationSeverity::Error,
856            code: "NO_TAGGED_STRUCTURE".to_string(),
857            message: "Document must have tagged structure for accessibility".to_string(),
858            node_id: catalog_nodes.first().copied(),
859            location: Some("Document catalog".to_string()),
860            suggestion: Some("Add tagged structure to the document".to_string()),
861        });
862    }
863}
864
865/// Constraint: No transparency allowed
866pub struct NoTransparencyConstraint;
867
868impl SchemaConstraint for NoTransparencyConstraint {
869    fn name(&self) -> &str {
870        "no-transparency"
871    }
872
873    fn description(&self) -> &str {
874        "Document must not use transparency features"
875    }
876
877    fn category(&self) -> ConstraintCategory {
878        ConstraintCategory::Graphics
879    }
880
881    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
882        // This is a simplified check - in practice would need to examine graphics states,
883        // blend modes, transparency groups, etc.
884        let mut transparency_found = false;
885
886        // Check for ExtGState entries that might contain transparency
887        for node in document.ast.get_all_nodes() {
888            if let PdfValue::Dictionary(dict) = &node.value {
889                if let Some(PdfValue::Dictionary(resources)) = dict.get("Resources") {
890                    if let Some(PdfValue::Dictionary(ext_gstate)) = resources.get("ExtGState") {
891                        for (_, gstate_value) in ext_gstate {
892                            if let PdfValue::Dictionary(gstate_dict) = gstate_value {
893                                if gstate_dict.contains_key("ca")
894                                    || gstate_dict.contains_key("CA")
895                                    || gstate_dict.contains_key("BM")
896                                    || gstate_dict.contains_key("SMask")
897                                {
898                                    transparency_found = true;
899                                    report.add_issue(ValidationIssue {
900                                        severity: ValidationSeverity::Error,
901                                        code: "TRANSPARENCY_NOT_ALLOWED".to_string(),
902                                        message:
903                                            "Transparency features are not allowed in this profile"
904                                                .to_string(),
905                                        node_id: Some(node.id),
906                                        location: Some("Graphics state".to_string()),
907                                        suggestion: Some(
908                                            "Remove transparency effects from the document"
909                                                .to_string(),
910                                        ),
911                                    });
912                                }
913                            }
914                        }
915                    }
916                }
917            }
918        }
919
920        if !transparency_found {
921            report.add_passed_check();
922        }
923    }
924}
925
926/// Constraint: No embedded files allowed
927pub struct NoEmbeddedFilesConstraint;
928
929impl SchemaConstraint for NoEmbeddedFilesConstraint {
930    fn name(&self) -> &str {
931        "no-embedded-files"
932    }
933
934    fn description(&self) -> &str {
935        "Document must not contain embedded files"
936    }
937
938    fn category(&self) -> ConstraintCategory {
939        ConstraintCategory::Content
940    }
941
942    fn required_node_types(&self) -> Vec<NodeType> {
943        vec![NodeType::EmbeddedFile]
944    }
945
946    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
947        let embedded_files = document.ast.find_nodes_by_type(NodeType::EmbeddedFile);
948
949        if !embedded_files.is_empty() {
950            for file_node in embedded_files {
951                report.add_issue(ValidationIssue {
952                    severity: ValidationSeverity::Error,
953                    code: "EMBEDDED_FILE_NOT_ALLOWED".to_string(),
954                    message: "Embedded files are not allowed in this profile".to_string(),
955                    node_id: Some(file_node),
956                    location: Some("Embedded file".to_string()),
957                    suggestion: Some("Remove embedded files from the document".to_string()),
958                });
959            }
960        } else {
961            report.add_passed_check();
962        }
963    }
964}
965
966/// Constraint: Fonts should define Encoding/ToUnicode appropriately
967pub struct FontCMapEncodingConstraint;
968
969impl SchemaConstraint for FontCMapEncodingConstraint {
970    fn name(&self) -> &str {
971        "font-encoding-cmap"
972    }
973
974    fn description(&self) -> &str {
975        "Fonts should define Encoding and/or ToUnicode mappings"
976    }
977
978    fn category(&self) -> ConstraintCategory {
979        ConstraintCategory::Fonts
980    }
981
982    fn iso_reference(&self) -> Option<&str> {
983        Some("ISO 32000-2:2020 Font dictionaries and ToUnicode CMaps")
984    }
985
986    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
987        let font_nodes = document.ast.find_nodes_by_type(NodeType::Font);
988        let cid_nodes = document.ast.find_nodes_by_type(NodeType::CIDFont);
989
990        for font_id in font_nodes.into_iter().chain(cid_nodes.into_iter()) {
991            let mut has_encoding = false;
992            let mut has_tounicode = false;
993
994            if let Some(node) = document.ast.get_node(font_id) {
995                if let Some(dict) = node.as_dict() {
996                    if dict.contains_key("Encoding") {
997                        has_encoding = true;
998                    }
999                    if dict.contains_key("ToUnicode") {
1000                        has_tounicode = true;
1001                    }
1002                    if let Some(PdfValue::Name(subtype)) = dict.get("Subtype") {
1003                        if subtype.without_slash() == "Type0" && !dict.contains_key("ToUnicode") {
1004                            has_tounicode = false;
1005                        }
1006                    }
1007                }
1008
1009                let children = document.ast.get_children(font_id);
1010                for child in children {
1011                    if let Some(child_node) = document.ast.get_node(child) {
1012                        if child_node.node_type == NodeType::Encoding {
1013                            has_encoding = true;
1014                        }
1015                        if child_node.node_type == NodeType::ToUnicode {
1016                            has_tounicode = true;
1017                            if !matches!(child_node.value, PdfValue::Stream(_)) {
1018                                report.add_issue(ValidationIssue {
1019                                    severity: ValidationSeverity::Warning,
1020                                    code: "TOUNICODE_NOT_STREAM".to_string(),
1021                                    message: "ToUnicode node is not a stream".to_string(),
1022                                    node_id: Some(child),
1023                                    location: Some("Font ToUnicode".to_string()),
1024                                    suggestion: Some("Ensure ToUnicode is a stream".to_string()),
1025                                });
1026                            }
1027                        }
1028                    }
1029                }
1030            }
1031
1032            if !has_encoding && !has_tounicode {
1033                report.add_issue(ValidationIssue {
1034                    severity: ValidationSeverity::Warning,
1035                    code: "FONT_ENCODING_MISSING".to_string(),
1036                    message: "Font missing Encoding/ToUnicode mappings".to_string(),
1037                    node_id: Some(font_id),
1038                    location: Some("Font dictionary".to_string()),
1039                    suggestion: Some("Provide Encoding or ToUnicode mapping".to_string()),
1040                });
1041            } else {
1042                report.add_passed_check();
1043            }
1044        }
1045    }
1046}
1047
1048/// Constraint: Document structure must be valid
1049pub struct ValidStructureConstraint;
1050
1051impl SchemaConstraint for ValidStructureConstraint {
1052    fn name(&self) -> &str {
1053        "valid-structure"
1054    }
1055
1056    fn description(&self) -> &str {
1057        "Document must have valid PDF structure"
1058    }
1059
1060    fn category(&self) -> ConstraintCategory {
1061        ConstraintCategory::Structure
1062    }
1063
1064    fn iso_reference(&self) -> Option<&str> {
1065        Some("ISO 32000-2:2020 Document structure")
1066    }
1067
1068    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
1069        // Check for cycles in the graph
1070        if document.ast.is_cyclic() {
1071            report.add_issue(ValidationIssue {
1072                severity: ValidationSeverity::Error,
1073                code: "CYCLIC_STRUCTURE".to_string(),
1074                message: "Document structure contains cycles".to_string(),
1075                node_id: None,
1076                location: Some("Document structure".to_string()),
1077                suggestion: Some("Remove circular references from the document".to_string()),
1078            });
1079        } else {
1080            report.add_passed_check();
1081        }
1082    }
1083}
1084
1085// Additional constraints for specific profiles...
1086
1087/// Constraint: Color space restrictions for PDF/X
1088pub struct ColorSpaceConstraint;
1089
1090impl SchemaConstraint for ColorSpaceConstraint {
1091    fn name(&self) -> &str {
1092        "color-space"
1093    }
1094
1095    fn description(&self) -> &str {
1096        "Color spaces must comply with PDF/X requirements"
1097    }
1098
1099    fn category(&self) -> ConstraintCategory {
1100        ConstraintCategory::Graphics
1101    }
1102
1103    fn check(&self, _document: &PdfDocument, report: &mut ValidationReport) {
1104        let document = _document;
1105        let mut has_issue = false;
1106
1107        let catalog_dict = document.get_catalog().cloned();
1108        let mut has_output_intents = false;
1109        if let Some(catalog) = &catalog_dict {
1110            if catalog.contains_key("OutputIntents") {
1111                has_output_intents = true;
1112            }
1113        }
1114
1115        if !has_output_intents {
1116            has_issue = true;
1117            report.add_issue(ValidationIssue {
1118                severity: ValidationSeverity::Error,
1119                code: "OUTPUT_INTENTS_MISSING".to_string(),
1120                message: "PDF/X requires OutputIntents for color management".to_string(),
1121                node_id: document.catalog,
1122                location: Some("Catalog".to_string()),
1123                suggestion: Some("Add OutputIntents to the catalog".to_string()),
1124            });
1125        }
1126
1127        let pages = document.ast.find_nodes_by_type(NodeType::Page);
1128        for page_id in pages {
1129            if let Some(page) = document.ast.get_node(page_id) {
1130                if let PdfValue::Dictionary(dict) = &page.value {
1131                    if let Some(resources_value) = dict.get("Resources") {
1132                        if let Some(resources) = resolve_dict_from_value(document, resources_value)
1133                        {
1134                            if let Some(colorspaces_value) = resources.get("ColorSpace") {
1135                                if value_contains_name(colorspaces_value, "DeviceRGB") {
1136                                    has_issue = true;
1137                                    report.add_issue(ValidationIssue {
1138                                        severity: ValidationSeverity::Error,
1139                                        code: "DEVICE_RGB_DISALLOWED".to_string(),
1140                                        message: "DeviceRGB color space is not permitted in PDF/X"
1141                                            .to_string(),
1142                                        node_id: Some(page_id),
1143                                        location: Some("Page resources ColorSpace".to_string()),
1144                                        suggestion: Some(
1145                                            "Use DeviceCMYK/Separation/ICCBased with OutputIntent"
1146                                                .to_string(),
1147                                        ),
1148                                    });
1149                                }
1150                            }
1151                        }
1152                    }
1153                }
1154            }
1155        }
1156
1157        let image_nodes = document.ast.find_nodes_by_type(NodeType::ImageXObject);
1158        for image_id in image_nodes {
1159            if let Some(image) = document.ast.get_node(image_id) {
1160                if let PdfValue::Dictionary(dict) = &image.value {
1161                    if let Some(colorspace_value) = dict.get("ColorSpace") {
1162                        if value_contains_name(colorspace_value, "DeviceRGB") {
1163                            has_issue = true;
1164                            report.add_issue(ValidationIssue {
1165                                severity: ValidationSeverity::Error,
1166                                code: "IMAGE_DEVICE_RGB_DISALLOWED".to_string(),
1167                                message: "Image uses DeviceRGB which is not permitted in PDF/X"
1168                                    .to_string(),
1169                                node_id: Some(image_id),
1170                                location: Some("Image XObject ColorSpace".to_string()),
1171                                suggestion: Some(
1172                                    "Convert images to CMYK or ICCBased with OutputIntent"
1173                                        .to_string(),
1174                                ),
1175                            });
1176                        }
1177                    }
1178                }
1179            }
1180        }
1181
1182        if !has_issue {
1183            report.add_passed_check();
1184        }
1185    }
1186}
1187
1188/// Constraint: TrimBox required for PDF/X
1189pub struct TrimBoxConstraint;
1190
1191impl SchemaConstraint for TrimBoxConstraint {
1192    fn name(&self) -> &str {
1193        "trim-box"
1194    }
1195
1196    fn description(&self) -> &str {
1197        "Pages must have TrimBox for print production"
1198    }
1199
1200    fn category(&self) -> ConstraintCategory {
1201        ConstraintCategory::Graphics
1202    }
1203
1204    fn check(&self, document: &PdfDocument, report: &mut ValidationReport) {
1205        let pages = document.ast.find_nodes_by_type(NodeType::Page);
1206
1207        for page_id in pages {
1208            if let Some(page) = document.ast.get_node(page_id) {
1209                if let PdfValue::Dictionary(dict) = &page.value {
1210                    if !dict.contains_key("TrimBox") {
1211                        report.add_issue(ValidationIssue {
1212                            severity: ValidationSeverity::Warning,
1213                            code: "TRIM_BOX_MISSING".to_string(),
1214                            message: "Page should have TrimBox for print production".to_string(),
1215                            node_id: Some(page_id),
1216                            location: Some("Page dictionary".to_string()),
1217                            suggestion: Some("Add TrimBox to page dictionary".to_string()),
1218                        });
1219                    }
1220                }
1221            }
1222        }
1223
1224        report.add_passed_check();
1225    }
1226}
1227
1228// PDF/UA specific constraints
1229
1230/// Constraint: Accessibility metadata required
1231pub struct AccessibilityMetadataConstraint;
1232
1233impl SchemaConstraint for AccessibilityMetadataConstraint {
1234    fn name(&self) -> &str {
1235        "accessibility-metadata"
1236    }
1237
1238    fn description(&self) -> &str {
1239        "Document must contain accessibility metadata"
1240    }
1241
1242    fn category(&self) -> ConstraintCategory {
1243        ConstraintCategory::Accessibility
1244    }
1245
1246    fn check(&self, _document: &PdfDocument, report: &mut ValidationReport) {
1247        let document = _document;
1248        let catalog = match document.get_catalog() {
1249            Some(catalog) => catalog,
1250            None => {
1251                report.add_issue(ValidationIssue {
1252                    severity: ValidationSeverity::Error,
1253                    code: "CATALOG_MISSING".to_string(),
1254                    message: "Catalog missing; cannot validate accessibility metadata".to_string(),
1255                    node_id: None,
1256                    location: Some("Catalog".to_string()),
1257                    suggestion: Some("Ensure document has a catalog dictionary".to_string()),
1258                });
1259                return;
1260            }
1261        };
1262
1263        let metadata_value = match catalog.get("Metadata") {
1264            Some(value) => value,
1265            None => {
1266                report.add_issue(ValidationIssue {
1267                    severity: ValidationSeverity::Error,
1268                    code: "ACCESSIBILITY_METADATA_MISSING".to_string(),
1269                    message: "PDF/UA requires XMP metadata stream in catalog".to_string(),
1270                    node_id: document.catalog,
1271                    location: Some("Catalog".to_string()),
1272                    suggestion: Some("Add Metadata stream with XMP packet".to_string()),
1273                });
1274                return;
1275            }
1276        };
1277
1278        let stream = match resolve_stream_from_value(document, metadata_value) {
1279            Some(stream) => stream,
1280            None => {
1281                report.add_issue(ValidationIssue {
1282                    severity: ValidationSeverity::Error,
1283                    code: "METADATA_NOT_STREAM".to_string(),
1284                    message: "Catalog Metadata entry must be a stream".to_string(),
1285                    node_id: document.catalog,
1286                    location: Some("Catalog Metadata".to_string()),
1287                    suggestion: Some("Ensure Metadata is a stream object".to_string()),
1288                });
1289                return;
1290            }
1291        };
1292
1293        let subtype_ok = stream
1294            .dict
1295            .get("Subtype")
1296            .and_then(PdfValue::as_name)
1297            .map(|name| name.without_slash() == "XML")
1298            .unwrap_or(false);
1299
1300        let type_ok = stream
1301            .dict
1302            .get("Type")
1303            .and_then(PdfValue::as_name)
1304            .map(|name| name.without_slash() == "Metadata")
1305            .unwrap_or(true);
1306
1307        if !subtype_ok || !type_ok {
1308            report.add_issue(ValidationIssue {
1309                severity: ValidationSeverity::Error,
1310                code: "METADATA_STREAM_INVALID".to_string(),
1311                message: "Metadata stream must have Type=Metadata and Subtype=XML".to_string(),
1312                node_id: document.catalog,
1313                location: Some("Metadata stream".to_string()),
1314                suggestion: Some("Fix Metadata stream dictionary entries".to_string()),
1315            });
1316            return;
1317        }
1318
1319        if let Some(bytes) = stream.data.as_bytes() {
1320            if !bytes.windows(9).any(|w| w == b"x:xmpmeta")
1321                && !bytes.windows(10).any(|w| w == b"<x:xmpmeta")
1322            {
1323                report.add_issue(ValidationIssue {
1324                    severity: ValidationSeverity::Warning,
1325                    code: "XMP_PACKET_MISSING".to_string(),
1326                    message: "Metadata stream does not appear to contain an XMP packet".to_string(),
1327                    node_id: document.catalog,
1328                    location: Some("Metadata stream".to_string()),
1329                    suggestion: Some("Embed a valid XMP packet".to_string()),
1330                });
1331                return;
1332            }
1333        }
1334
1335        report.add_passed_check();
1336    }
1337}
1338
1339/// Constraint: Alternative text required
1340pub struct AltTextConstraint;
1341
1342impl SchemaConstraint for AltTextConstraint {
1343    fn name(&self) -> &str {
1344        "alt-text"
1345    }
1346
1347    fn description(&self) -> &str {
1348        "Images and figures must have alternative text"
1349    }
1350
1351    fn category(&self) -> ConstraintCategory {
1352        ConstraintCategory::Accessibility
1353    }
1354
1355    fn check(&self, _document: &PdfDocument, report: &mut ValidationReport) {
1356        let document = _document;
1357        let struct_elems = document.ast.find_nodes_by_type(NodeType::StructElem);
1358
1359        if struct_elems.is_empty() {
1360            report.add_issue(ValidationIssue {
1361                severity: ValidationSeverity::Error,
1362                code: "STRUCT_ELEM_MISSING".to_string(),
1363                message: "PDF/UA requires structure elements for alternative text".to_string(),
1364                node_id: document.catalog,
1365                location: Some("Structure tree".to_string()),
1366                suggestion: Some("Add StructTreeRoot and StructElem entries".to_string()),
1367            });
1368            return;
1369        }
1370
1371        let mut missing_alt = false;
1372        for elem_id in struct_elems {
1373            if let Some(elem) = document.ast.get_node(elem_id) {
1374                if let PdfValue::Dictionary(dict) = &elem.value {
1375                    let is_figure = dict
1376                        .get("S")
1377                        .and_then(PdfValue::as_name)
1378                        .map(|name| {
1379                            name.without_slash() == "Figure"
1380                                || name.without_slash() == "Formula"
1381                                || name.without_slash() == "Table"
1382                        })
1383                        .unwrap_or(false);
1384                    if is_figure && !dict.contains_key("Alt") {
1385                        missing_alt = true;
1386                        report.add_issue(ValidationIssue {
1387                            severity: ValidationSeverity::Error,
1388                            code: "ALT_TEXT_MISSING".to_string(),
1389                            message: "Figure/Table structure element missing Alt text".to_string(),
1390                            node_id: Some(elem_id),
1391                            location: Some("StructElem".to_string()),
1392                            suggestion: Some("Add Alt entry to StructElem".to_string()),
1393                        });
1394                    }
1395                }
1396            }
1397        }
1398
1399        if !missing_alt {
1400            report.add_passed_check();
1401        }
1402    }
1403}
1404
1405/// Constraint: Language specification required
1406pub struct LanguageSpecificationConstraint;
1407
1408impl SchemaConstraint for LanguageSpecificationConstraint {
1409    fn name(&self) -> &str {
1410        "language-specification"
1411    }
1412
1413    fn description(&self) -> &str {
1414        "Document must specify primary language"
1415    }
1416
1417    fn category(&self) -> ConstraintCategory {
1418        ConstraintCategory::Accessibility
1419    }
1420
1421    fn check(&self, _document: &PdfDocument, report: &mut ValidationReport) {
1422        let document = _document;
1423        let catalog = match document.get_catalog() {
1424            Some(catalog) => catalog,
1425            None => {
1426                report.add_issue(ValidationIssue {
1427                    severity: ValidationSeverity::Error,
1428                    code: "CATALOG_MISSING".to_string(),
1429                    message: "Catalog missing; cannot validate language".to_string(),
1430                    node_id: None,
1431                    location: Some("Catalog".to_string()),
1432                    suggestion: Some("Ensure document has a catalog dictionary".to_string()),
1433                });
1434                return;
1435            }
1436        };
1437
1438        let lang_value = catalog.get("Lang").and_then(PdfValue::as_string);
1439        if let Some(lang) = lang_value {
1440            if lang.as_bytes().is_empty() {
1441                report.add_issue(ValidationIssue {
1442                    severity: ValidationSeverity::Error,
1443                    code: "LANG_EMPTY".to_string(),
1444                    message: "Catalog Lang entry must not be empty".to_string(),
1445                    node_id: document.catalog,
1446                    location: Some("Catalog".to_string()),
1447                    suggestion: Some("Set a valid language code (e.g. en-US)".to_string()),
1448                });
1449                return;
1450            }
1451            report.add_passed_check();
1452        } else {
1453            report.add_issue(ValidationIssue {
1454                severity: ValidationSeverity::Error,
1455                code: "LANG_MISSING".to_string(),
1456                message: "PDF/UA requires catalog Lang entry".to_string(),
1457                node_id: document.catalog,
1458                location: Some("Catalog".to_string()),
1459                suggestion: Some("Set Lang in catalog (e.g. en-US)".to_string()),
1460            });
1461        }
1462    }
1463}
1464
1465/// Constraint: Logical reading order required
1466pub struct LogicalReadingOrderConstraint;
1467
1468impl SchemaConstraint for LogicalReadingOrderConstraint {
1469    fn name(&self) -> &str {
1470        "logical-reading-order"
1471    }
1472
1473    fn description(&self) -> &str {
1474        "Content must have logical reading order"
1475    }
1476
1477    fn category(&self) -> ConstraintCategory {
1478        ConstraintCategory::Accessibility
1479    }
1480
1481    fn check(&self, _document: &PdfDocument, report: &mut ValidationReport) {
1482        let document = _document;
1483        let catalog = match document.get_catalog() {
1484            Some(catalog) => catalog,
1485            None => {
1486                report.add_issue(ValidationIssue {
1487                    severity: ValidationSeverity::Error,
1488                    code: "CATALOG_MISSING".to_string(),
1489                    message: "Catalog missing; cannot validate reading order".to_string(),
1490                    node_id: None,
1491                    location: Some("Catalog".to_string()),
1492                    suggestion: Some("Ensure document has a catalog dictionary".to_string()),
1493                });
1494                return;
1495            }
1496        };
1497
1498        let struct_root_value = match catalog.get("StructTreeRoot") {
1499            Some(value) => value,
1500            None => {
1501                report.add_issue(ValidationIssue {
1502                    severity: ValidationSeverity::Error,
1503                    code: "STRUCT_TREE_ROOT_MISSING".to_string(),
1504                    message: "PDF/UA requires StructTreeRoot".to_string(),
1505                    node_id: document.catalog,
1506                    location: Some("Catalog".to_string()),
1507                    suggestion: Some("Add StructTreeRoot to catalog".to_string()),
1508                });
1509                return;
1510            }
1511        };
1512
1513        let struct_root = match resolve_dict_from_value(document, struct_root_value) {
1514            Some(dict) => dict,
1515            None => {
1516                report.add_issue(ValidationIssue {
1517                    severity: ValidationSeverity::Error,
1518                    code: "STRUCT_TREE_ROOT_INVALID".to_string(),
1519                    message: "StructTreeRoot must be a dictionary".to_string(),
1520                    node_id: document.catalog,
1521                    location: Some("StructTreeRoot".to_string()),
1522                    suggestion: Some("Ensure StructTreeRoot is a valid dictionary".to_string()),
1523                });
1524                return;
1525            }
1526        };
1527
1528        let has_parent_tree = struct_root.contains_key("ParentTree");
1529        let has_k = struct_root.contains_key("K");
1530
1531        if !has_parent_tree || !has_k {
1532            report.add_issue(ValidationIssue {
1533                severity: ValidationSeverity::Error,
1534                code: "READING_ORDER_INCOMPLETE".to_string(),
1535                message: "StructTreeRoot must define ParentTree and K for reading order"
1536                    .to_string(),
1537                node_id: document.catalog,
1538                location: Some("StructTreeRoot".to_string()),
1539                suggestion: Some("Populate StructTreeRoot ParentTree and K entries".to_string()),
1540            });
1541            return;
1542        }
1543
1544        report.add_passed_check();
1545    }
1546}