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
45pub 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
96pub 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
132pub 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
170pub 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
246pub 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
282pub 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
338pub 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 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
408pub 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
451pub 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
520pub 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
570pub 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
633pub 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
665pub 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
706pub 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
747pub 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 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
817pub 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 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
865pub 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 let mut transparency_found = false;
885
886 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
926pub 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
966pub 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
1048pub 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 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
1085pub 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
1188pub 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
1228pub 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
1339pub 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
1405pub 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
1465pub 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}