pdfrb 0.7.1 → 0.7.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/RELEASE.md +17 -11
  4. data/data/pdfrb/layout/hyphenation_en.txt +408 -0
  5. data/lib/pdfrb/color/default_profile.rb +213 -0
  6. data/lib/pdfrb/color/icc_validator.rb +69 -0
  7. data/lib/pdfrb/color.rb +2 -0
  8. data/lib/pdfrb/conformance/ltv.rb +112 -0
  9. data/lib/pdfrb/conformance/pades.rb +198 -0
  10. data/lib/pdfrb/conformance/pdf_2_af.rb +185 -0
  11. data/lib/pdfrb/conformance/pdf_a.rb +123 -0
  12. data/lib/pdfrb/conformance/pdf_a4_deep.rb +94 -0
  13. data/lib/pdfrb/conformance/pdf_ua2_deep.rb +93 -0
  14. data/lib/pdfrb/conformance/pdf_ua_tagging_deep.rb +125 -0
  15. data/lib/pdfrb/conformance/pdf_vt.rb +128 -0
  16. data/lib/pdfrb/conformance/pdf_x.rb +66 -1
  17. data/lib/pdfrb/conformance/tagged_pdf.rb +182 -0
  18. data/lib/pdfrb/conformance.rb +8 -0
  19. data/lib/pdfrb/content/parser.rb +82 -0
  20. data/lib/pdfrb/digital_signature/timestamp_client.rb +86 -0
  21. data/lib/pdfrb/digital_signature.rb +1 -0
  22. data/lib/pdfrb/document.rb +29 -0
  23. data/lib/pdfrb/encryption/public_key_security_handler.rb +146 -0
  24. data/lib/pdfrb/encryption/standard_security_handler.rb +98 -3
  25. data/lib/pdfrb/encryption/v5_writer.rb +108 -0
  26. data/lib/pdfrb/encryption.rb +3 -0
  27. data/lib/pdfrb/font_loader/type3.rb +65 -0
  28. data/lib/pdfrb/font_loader.rb +1 -0
  29. data/lib/pdfrb/image_loader/gif.rb +246 -0
  30. data/lib/pdfrb/image_loader/tiff.rb +257 -0
  31. data/lib/pdfrb/image_loader.rb +2 -0
  32. data/lib/pdfrb/layout/font_fallback.rb +203 -0
  33. data/lib/pdfrb/layout/hyphenation.rb +120 -0
  34. data/lib/pdfrb/layout/justification_kashidas.rb +72 -0
  35. data/lib/pdfrb/layout/multi_cell_text_layout.rb +65 -0
  36. data/lib/pdfrb/layout/multi_page_table_box.rb +155 -0
  37. data/lib/pdfrb/layout/polygon_frame.rb +106 -0
  38. data/lib/pdfrb/layout/table_box.rb +154 -23
  39. data/lib/pdfrb/layout/text_shaper.rb +129 -0
  40. data/lib/pdfrb/layout.rb +7 -0
  41. data/lib/pdfrb/source/linearization_reader.rb +76 -0
  42. data/lib/pdfrb/source/recovery.rb +65 -1
  43. data/lib/pdfrb/source/tokenizer.rb +21 -0
  44. data/lib/pdfrb/source.rb +1 -0
  45. data/lib/pdfrb/task/thumbnail.rb +133 -0
  46. data/lib/pdfrb/task.rb +1 -0
  47. data/lib/pdfrb/version.rb +1 -1
  48. metadata +28 -2
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ module Pdfrb
5
+ module Conformance
6
+ # PDF 2.0 Associated Files (AF) validation per Application Note
7
+ # 002 (sources at ~/src/pdfa/appnote-pdf20-002-af/). The AF
8
+ # mechanism attaches external files to PDF objects with a typed
9
+ # relationship (Source, Data, Alternative, Supplement,
10
+ # EncryptedPayload, Unspecified).
11
+ #
12
+ # This rule set checks:
13
+ # * /AF arrays are present where required (PDF 2.0 catalog).
14
+ # * Each /AF entry is a /Type /AssociatedFile or FileSpec dict
15
+ # with an /AFRelationship.
16
+ # * /AFRelationship is one of the six allowed values.
17
+ # * FileSpec dicts carry /UF + /EF.
18
+ # * EmbeddedFile streams carry /Subtype (MIME type).
19
+ module Pdf2AF
20
+ ALLOWED_RELATIONSHIPS = %i[Source Data Alternative Supplement
21
+ EncryptedPayload Unspecified].freeze
22
+
23
+ module_function
24
+
25
+ RULESET = RuleSet.new("PDF 2.0 AF").tap do |rs|
26
+ rs.register(Rule.new(
27
+ id: "af-1",
28
+ description: "/AF entries must declare AFRelationship",
29
+ severity: :error,
30
+ spec_clause: "PDF 2.0 App Note 002 3.1",
31
+ check: ->(doc) {
32
+ violations = []
33
+ each_af_entry(doc) do |container, entry|
34
+ next if relationship_of(entry)
35
+
36
+ violations << Violation.new(
37
+ rule_id: "af-1",
38
+ message: "/AF entry on #{container_label(container)} missing /AFRelationship",
39
+ object: container_label(container),
40
+ severity: :error,
41
+ spec_clause: "PDF 2.0 App Note 002 3.1"
42
+ )
43
+ end
44
+ violations
45
+ }
46
+ ))
47
+
48
+ rs.register(Rule.new(
49
+ id: "af-2",
50
+ description: "/AFRelationship must be one of the allowed values",
51
+ severity: :error,
52
+ spec_clause: "PDF 2.0 App Note 002 3.2",
53
+ check: ->(doc) {
54
+ violations = []
55
+ each_af_entry(doc) do |container, entry|
56
+ rel = relationship_of(entry)
57
+ next unless rel
58
+ next if ALLOWED_RELATIONSHIPS.include?(rel.to_sym)
59
+
60
+ violations << Violation.new(
61
+ rule_id: "af-2",
62
+ message: "/AFRelationship '#{rel}' is not one of #{ALLOWED_RELATIONSHIPS.join(', ')}",
63
+ object: container_label(container),
64
+ severity: :error,
65
+ spec_clause: "PDF 2.0 App Note 002 3.2"
66
+ )
67
+ end
68
+ violations
69
+ }
70
+ ))
71
+
72
+ rs.register(Rule.new(
73
+ id: "af-3",
74
+ description: "FileSpec dicts must carry /UF and /EF",
75
+ severity: :error,
76
+ spec_clause: "PDF 2.0 7.11.3",
77
+ check: ->(doc) {
78
+ violations = []
79
+ each_filespec(doc) do |_container, fs|
80
+ ok = fs[:UF] && fs[:EF]
81
+ next if ok
82
+
83
+ violations << Violation.new(
84
+ rule_id: "af-3",
85
+ message: "FileSpec missing /UF or /EF",
86
+ object: "FileSpec",
87
+ severity: :error,
88
+ spec_clause: "PDF 2.0 7.11.3"
89
+ )
90
+ end
91
+ violations
92
+ }
93
+ ))
94
+
95
+ rs.register(Rule.new(
96
+ id: "af-4",
97
+ description: "EmbeddedFile streams should declare /Subtype MIME",
98
+ severity: :warning,
99
+ spec_clause: "PDF 2.0 7.11.4",
100
+ check: ->(doc) {
101
+ violations = []
102
+ doc.each_indirect_object do |obj|
103
+ next unless obj.value[:Type] == :EmbeddedFile
104
+ next if obj.value[:Subtype]
105
+
106
+ violations << Violation.new(
107
+ rule_id: "af-4",
108
+ message: "EmbeddedFile missing /Subtype (MIME type)",
109
+ object: "EmbeddedFile",
110
+ severity: :warning,
111
+ spec_clause: "PDF 2.0 7.11.4"
112
+ )
113
+ end
114
+ violations
115
+ }
116
+ ))
117
+ end
118
+
119
+ def validate(document)
120
+ RULESET.validate(document)
121
+ end
122
+
123
+ # Walk every indirect object that has an /AF key, yielding
124
+ # (container_object, af_entry) for each entry in each /AF
125
+ # array. /AF entries may be References (resolves) or dicts.
126
+ def each_af_entry(document)
127
+ document.each_indirect_object do |container|
128
+ af = container.value[:AF]
129
+ next unless af
130
+
131
+ af_value = af.is_a?(Pdfrb::Model::PdfArray) ? af.value : af
132
+ entries = af_value.is_a?(::Array) ? af_value : [af_value]
133
+ entries.each do |e|
134
+ resolved = e.is_a?(Pdfrb::Model::Reference) ? document.object(e) : e
135
+ yield container, resolved if resolved
136
+ end
137
+ end
138
+ end
139
+
140
+ # Walk every FileSpec dict in the document.
141
+ def each_filespec(document)
142
+ document.each_indirect_object do |container|
143
+ fs = container.value[:Type] == :FileSpec ? container : nil
144
+ if fs
145
+ yield container, fs
146
+ next
147
+ end
148
+
149
+ # Also pick up FileSpecs referenced from /AF arrays.
150
+ af = container.value[:AF]
151
+ next unless af
152
+
153
+ af_value = af.is_a?(Pdfrb::Model::PdfArray) ? af.value : af
154
+ entries = af_value.is_a?(::Array) ? af_value : [af_value]
155
+ entries.each do |e|
156
+ resolved = e.is_a?(Pdfrb::Model::Reference) ? document.object(e) : e
157
+ next unless resolved && resolved.value[:Type] == :FileSpec
158
+
159
+ yield container, resolved
160
+ end
161
+ end
162
+ end
163
+
164
+ def relationship_of(entry)
165
+ return nil unless entry
166
+
167
+ value = entry.is_a?(Pdfrb::Model::Cos::Dictionary) ? entry.value : entry
168
+ return nil unless value.is_a?(::Hash)
169
+
170
+ # AssociatedFile dicts put the relationship on
171
+ # /AFRelationship; FileSpecs put it on the same key when
172
+ # they're AF-related.
173
+ value[:AFRelationship]
174
+ end
175
+
176
+ def container_label(obj)
177
+ type = obj.value[:Type]
178
+ return "Object/#{obj.oid}" unless type
179
+
180
+ "Object/#{type}/#{obj.oid}"
181
+ end
182
+ end
183
+ end
184
+ end
185
+ # rubocop:enable Metrics/BlockLength
@@ -80,6 +80,122 @@ module Pdfrb
80
80
  ))
81
81
  end
82
82
 
83
+ # Deep preflight rules common to all PDF/A levels. Each is a
84
+ # categorical ban: JavaScript, PS XObjects, non-A-3 embedded
85
+ # files, and unencoded text operators.
86
+ PREFLIGHT_DEEP = RuleSet.new("PDF/A-preflight").tap do |rs|
87
+ rs.register(Rule.new(
88
+ id: "preflight-1",
89
+ description: "JavaScript is forbidden",
90
+ severity: :error,
91
+ spec_clause: "ISO 19005-1 6.6",
92
+ check: ->(doc) {
93
+ vs = []
94
+ catalog = doc.catalog
95
+ names = catalog[:Names]
96
+ names = doc.object(names) if names.is_a?(Pdfrb::Model::Reference)
97
+ if names && (names[:JavaScript] || (names.is_a?(::Hash) && names[:JavaScript]))
98
+ vs << Violation.new(
99
+ rule_id: "preflight-1",
100
+ message: "/Names /JavaScript is forbidden in PDF/A",
101
+ object: "Catalog/Names/JavaScript",
102
+ severity: :error,
103
+ spec_clause: "ISO 19005-1 6.6"
104
+ )
105
+ end
106
+ if catalog[:AA] || catalog[:OpenAction]
107
+ vs << Violation.new(
108
+ rule_id: "preflight-1",
109
+ message: "Additional actions (/AA) and /OpenAction with JavaScript are restricted in PDF/A",
110
+ object: "Catalog",
111
+ severity: :warning,
112
+ spec_clause: "ISO 19005-1 6.6"
113
+ )
114
+ end
115
+ vs.empty? ? nil : vs
116
+ }
117
+ ))
118
+
119
+ rs.register(Rule.new(
120
+ id: "preflight-2",
121
+ description: "PostScript XObjects are forbidden",
122
+ severity: :error,
123
+ spec_clause: "ISO 19005-1 6.2.3",
124
+ check: ->(doc) {
125
+ vs = []
126
+ doc.each_indirect_object do |obj|
127
+ next unless obj.value[:Type] == :XObject
128
+ next unless obj.value[:Subtype] == :PS
129
+
130
+ vs << Violation.new(
131
+ rule_id: "preflight-2",
132
+ message: "PostScript XObjects are forbidden in PDF/A",
133
+ object: "XObject",
134
+ severity: :error,
135
+ spec_clause: "ISO 19005-1 6.2.3"
136
+ )
137
+ end
138
+ vs.empty? ? nil : vs
139
+ }
140
+ ))
141
+
142
+ rs.register(Rule.new(
143
+ id: "preflight-3",
144
+ description: "EmbeddedFile streams restricted",
145
+ severity: :error,
146
+ spec_clause: "ISO 19005-3 6.4",
147
+ check: ->(_doc) {
148
+ # Only PDF/A-3 allows EmbeddedFile. The rule
149
+ # is permissive by default; the per-level
150
+ # rule sets filter it down for A-1 and A-2.
151
+ }
152
+ ))
153
+
154
+ rs.register(Rule.new(
155
+ id: "preflight-4",
156
+ description: "Encryption is forbidden",
157
+ severity: :error,
158
+ spec_clause: "ISO 19005-1 6.1",
159
+ check: ->(doc) {
160
+ next nil unless doc.trailer && doc.trailer[:Encrypt]
161
+
162
+ Violation.new(
163
+ rule_id: "preflight-4",
164
+ message: "Encryption is forbidden in PDF/A",
165
+ object: "Trailer/Encrypt",
166
+ severity: :error,
167
+ spec_clause: "ISO 19005-1 6.1"
168
+ )
169
+ }
170
+ ))
171
+ end
172
+
173
+ # Embeds files forbidden in PDF/A-1 and PDF/A-2 (only A-3
174
+ # permits them).
175
+ PREFLIGHT_NO_EMBED = RuleSet.new("PDF/A-no-embedded").tap do |rs|
176
+ rs.register(Rule.new(
177
+ id: "preflight-3-a1a2",
178
+ description: "EmbeddedFile streams are forbidden in PDF/A-1 and A-2",
179
+ severity: :error,
180
+ spec_clause: "ISO 19005-1 6.4",
181
+ check: ->(doc) {
182
+ vs = []
183
+ doc.each_indirect_object do |obj|
184
+ next unless obj.value[:Type] == :EmbeddedFile
185
+
186
+ vs << Violation.new(
187
+ rule_id: "preflight-3-a1a2",
188
+ message: "EmbeddedFile streams are forbidden in PDF/A-1 and A-2",
189
+ object: "EmbeddedFile",
190
+ severity: :error,
191
+ spec_clause: "ISO 19005-1 6.4"
192
+ )
193
+ end
194
+ vs.empty? ? nil : vs
195
+ }
196
+ ))
197
+ end
198
+
83
199
  A1_SPECIFIC = RuleSet.new("PDF/A-1-specific").tap do |rs|
84
200
  rs.register(Rule.new(
85
201
  id: "a1-1", description: "JPEG2000 not allowed in A-1",
@@ -295,21 +411,28 @@ module Pdfrb
295
411
  A1 = RuleSet.new("PDF/A-1").tap do |rs|
296
412
  SHARED.rules.each { |r| rs.register(r) }
297
413
  A1_SPECIFIC.rules.each { |r| rs.register(r) }
414
+ PREFLIGHT_DEEP.rules.each { |r| rs.register(r) }
415
+ PREFLIGHT_NO_EMBED.rules.each { |r| rs.register(r) }
298
416
  end
299
417
 
300
418
  A2 = RuleSet.new("PDF/A-2").tap do |rs|
301
419
  SHARED.rules.each { |r| rs.register(r) }
302
420
  A2_SPECIFIC.rules.each { |r| rs.register(r) }
421
+ PREFLIGHT_DEEP.rules.each { |r| rs.register(r) }
422
+ PREFLIGHT_NO_EMBED.rules.each { |r| rs.register(r) }
303
423
  end
304
424
 
305
425
  A3 = RuleSet.new("PDF/A-3").tap do |rs|
306
426
  SHARED.rules.each { |r| rs.register(r) }
307
427
  A3_SPECIFIC.rules.each { |r| rs.register(r) }
428
+ PREFLIGHT_DEEP.rules.each { |r| rs.register(r) }
308
429
  end
309
430
 
310
431
  A4 = RuleSet.new("PDF/A-4").tap do |rs|
311
432
  SHARED.rules.each { |r| rs.register(r) }
312
433
  A4_SPECIFIC.rules.each { |r| rs.register(r) }
434
+ PREFLIGHT_DEEP.rules.each { |r| rs.register(r) }
435
+ PREFLIGHT_NO_EMBED.rules.each { |r| rs.register(r) }
313
436
  end
314
437
 
315
438
  LEVEL_RULESETS = {
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ module Pdfrb
5
+ module Conformance
6
+ # PDF/A-4 deep checks (ISO 19005-4). Builds on the SHARED + A4
7
+ # rules in Pdfrb::Conformance::PdfA by adding additional
8
+ # structural requirements specific to PDF/A-4: PDF 2.0 base,
9
+ # XMP metadata required (not just recommended), /AF entries
10
+ # valid per App Note 002.
11
+ module PdfA4Deep
12
+ module_function
13
+
14
+ RULESET = RuleSet.new("PDF/A-4-deep").tap do |rs|
15
+ rs.register(Rule.new(
16
+ id: "a4deep-1",
17
+ description: "PDF/A-4 requires XMP /Metadata stream",
18
+ severity: :error,
19
+ spec_clause: "ISO 19005-4 6.1",
20
+ check: ->(doc) {
21
+ next nil if doc.catalog[:Metadata]
22
+
23
+ Violation.new(
24
+ rule_id: "a4deep-1",
25
+ message: "PDF/A-4 requires /Catalog/Metadata XMP stream",
26
+ object: "Catalog/Metadata",
27
+ severity: :error,
28
+ spec_clause: "ISO 19005-4 6.1"
29
+ )
30
+ }
31
+ ))
32
+
33
+ rs.register(Rule.new(
34
+ id: "a4deep-2",
35
+ description: "PDF/A-4 should have valid /OutputIntents when colour-managed",
36
+ severity: :warning,
37
+ spec_clause: "ISO 19005-4 6.2.4",
38
+ check: ->(doc) {
39
+ next nil if doc.catalog[:OutputIntents]
40
+
41
+ # Only flag when document has ICCBased color spaces
42
+ has_icc = false
43
+ doc.each_indirect_object do |obj|
44
+ next unless obj.value[:ColorSpace]
45
+
46
+ cs = obj.value[:ColorSpace]
47
+ cs_array = cs.is_a?(::Array) ? cs : [cs]
48
+ if cs_array.any?(:ICCBased)
49
+ has_icc = true
50
+ break
51
+ end
52
+ end
53
+ next nil unless has_icc
54
+
55
+ Violation.new(
56
+ rule_id: "a4deep-2",
57
+ message: "ICC-based color spaces require /OutputIntents in PDF/A-4",
58
+ object: "Catalog/OutputIntents",
59
+ severity: :warning,
60
+ spec_clause: "ISO 19005-4 6.2.4"
61
+ )
62
+ }
63
+ ))
64
+
65
+ rs.register(Rule.new(
66
+ id: "a4deep-3",
67
+ description: "PDF/A-4 forbids EmbeddedFile streams",
68
+ severity: :error,
69
+ spec_clause: "ISO 19005-4 6.4",
70
+ check: ->(doc) {
71
+ vs = []
72
+ doc.each_indirect_object do |obj|
73
+ next unless obj.value[:Type] == :EmbeddedFile
74
+
75
+ vs << Violation.new(
76
+ rule_id: "a4deep-3",
77
+ message: "PDF/A-4 forbids EmbeddedFile streams",
78
+ object: "EmbeddedFile",
79
+ severity: :error,
80
+ spec_clause: "ISO 19005-4 6.4"
81
+ )
82
+ end
83
+ vs.empty? ? nil : vs
84
+ }
85
+ ))
86
+ end
87
+
88
+ def validate(document)
89
+ RULESET.validate(document)
90
+ end
91
+ end
92
+ end
93
+ end
94
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ module Pdfrb
5
+ module Conformance
6
+ # PDF/UA-2 (ISO 14289-2) deep checks. PDF/UA-2 is rooted in
7
+ # PDF 2.0 and adds stricter requirements on top of PDF/UA-1:
8
+ # * PDF version >= 2.0
9
+ # * Structure tree with consistent /RoleMap
10
+ # * Heading hierarchy (H1-H6) without skipping levels
11
+ # * Every Figure, Formula, Table has /Alt or /ActualText
12
+ module PdfUA2Deep
13
+ module_function
14
+
15
+ RULESET = RuleSet.new("PDF/UA-2-deep").tap do |rs|
16
+ rs.register(Rule.new(
17
+ id: "ua2-1",
18
+ description: "PDF/UA-2 requires PDF 2.0",
19
+ severity: :error,
20
+ spec_clause: "ISO 14289-2 4.1",
21
+ check: ->(doc) {
22
+ v = doc.version.to_s
23
+ next nil if v >= "2.0"
24
+
25
+ Violation.new(
26
+ rule_id: "ua2-1",
27
+ message: "PDF/UA-2 requires PDF 2.0 (was #{v})",
28
+ object: "Header",
29
+ severity: :error,
30
+ spec_clause: "ISO 14289-2 4.1"
31
+ )
32
+ }
33
+ ))
34
+
35
+ rs.register(Rule.new(
36
+ id: "ua2-2",
37
+ description: "Heading hierarchy must not skip levels",
38
+ severity: :warning,
39
+ spec_clause: "ISO 14289-2 7.3",
40
+ check: ->(doc) {
41
+ vs = []
42
+ prev_level = 0
43
+ TaggedPdf.walk_structure(doc) do |elem|
44
+ s = elem[:S]&.to_s
45
+ next unless s&.match?(/\AH([1-6])\z/)
46
+
47
+ level = Regexp.last_match(1).to_i
48
+ if level > prev_level + 1 && prev_level.positive?
49
+ vs << Violation.new(
50
+ rule_id: "ua2-2",
51
+ message: "Heading H#{level} jumps from H#{prev_level}",
52
+ object: "StructElem/#{s}",
53
+ severity: :warning,
54
+ spec_clause: "ISO 14289-2 7.3"
55
+ )
56
+ end
57
+ prev_level = level
58
+ end
59
+ vs.empty? ? nil : vs
60
+ }
61
+ ))
62
+
63
+ rs.register(Rule.new(
64
+ id: "ua2-3",
65
+ description: "Formula elements require /Alt or /ActualText",
66
+ severity: :warning,
67
+ spec_clause: "ISO 14289-2 7.9",
68
+ check: ->(doc) {
69
+ vs = []
70
+ TaggedPdf.walk_structure(doc) do |elem|
71
+ next unless elem[:S] == :Formula
72
+ next if elem[:Alt] || elem[:ActualText]
73
+
74
+ vs << Violation.new(
75
+ rule_id: "ua2-3",
76
+ message: "Formula without /Alt or /ActualText",
77
+ object: "StructElem/Formula",
78
+ severity: :warning,
79
+ spec_clause: "ISO 14289-2 7.9"
80
+ )
81
+ end
82
+ vs.empty? ? nil : vs
83
+ }
84
+ ))
85
+ end
86
+
87
+ def validate(document)
88
+ RULESET.validate(document)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ # rubocop:enable Metrics/BlockLength
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ module Pdfrb
5
+ module Conformance
6
+ # PDF/UA-1 deep tagging checks (ISO 14289-1 §7). Extends the
7
+ # baseline PDF/UA rule set with structural-tree traversal rules:
8
+ # * Every /L (list) must contain /LI children
9
+ # * Every /LI must contain /LBody
10
+ # * Every /Table must contain /TR rows
11
+ # * Every /TH and /TD must have /Scope or headers linkage
12
+ # * Every /Figure, /Formula, /Table has /Alt or /ActualText
13
+ module PdfUATaggingDeep
14
+ module_function
15
+
16
+ RULESET = RuleSet.new("PDF/UA-tagging-deep").tap do |rs|
17
+ rs.register(Rule.new(
18
+ id: "tag-deep-1",
19
+ description: "List /L must have /LI children",
20
+ severity: :warning,
21
+ spec_clause: "ISO 14289-1 7.4",
22
+ check: ->(doc) {
23
+ vs = []
24
+ TaggedPdf.walk_structure(doc) do |elem|
25
+ next unless elem[:S] == :L
26
+
27
+ kids = TaggedPdf.structure_kids(elem, doc)
28
+ has_li = kids.any? { |k| k[:S] == :LI }
29
+ next if has_li
30
+
31
+ vs << Violation.new(
32
+ rule_id: "tag-deep-1",
33
+ message: "/L without /LI child",
34
+ object: "StructElem/L",
35
+ severity: :warning,
36
+ spec_clause: "ISO 14289-1 7.4"
37
+ )
38
+ end
39
+ vs.empty? ? nil : vs
40
+ }
41
+ ))
42
+
43
+ rs.register(Rule.new(
44
+ id: "tag-deep-2",
45
+ description: "/LI must have /LBody",
46
+ severity: :warning,
47
+ spec_clause: "ISO 14289-1 7.4",
48
+ check: ->(doc) {
49
+ vs = []
50
+ TaggedPdf.walk_structure(doc) do |elem|
51
+ next unless elem[:S] == :LI
52
+
53
+ kids = TaggedPdf.structure_kids(elem, doc)
54
+ has_body = kids.any? { |k| k[:S] == :LBody }
55
+ next if has_body
56
+
57
+ vs << Violation.new(
58
+ rule_id: "tag-deep-2",
59
+ message: "/LI without /LBody",
60
+ object: "StructElem/LI",
61
+ severity: :warning,
62
+ spec_clause: "ISO 14289-1 7.4"
63
+ )
64
+ end
65
+ vs.empty? ? nil : vs
66
+ }
67
+ ))
68
+
69
+ rs.register(Rule.new(
70
+ id: "tag-deep-3",
71
+ description: "/Table must have /TR rows",
72
+ severity: :warning,
73
+ spec_clause: "ISO 14289-1 7.5",
74
+ check: ->(doc) {
75
+ vs = []
76
+ TaggedPdf.walk_structure(doc) do |elem|
77
+ next unless elem[:S] == :Table
78
+
79
+ kids = TaggedPdf.structure_kids(elem, doc)
80
+ has_tr = kids.any? { |k| k[:S] == :TR }
81
+ next if has_tr
82
+
83
+ vs << Violation.new(
84
+ rule_id: "tag-deep-3",
85
+ message: "/Table without /TR child rows",
86
+ object: "StructElem/Table",
87
+ severity: :warning,
88
+ spec_clause: "ISO 14289-1 7.5"
89
+ )
90
+ end
91
+ vs.empty? ? nil : vs
92
+ }
93
+ ))
94
+
95
+ rs.register(Rule.new(
96
+ id: "tag-deep-4",
97
+ description: "/TH cells should declare /Scope",
98
+ severity: :warning,
99
+ spec_clause: "ISO 14289-1 7.5",
100
+ check: ->(doc) {
101
+ vs = []
102
+ TaggedPdf.walk_structure(doc) do |elem|
103
+ next unless elem[:S] == :TH
104
+ next if elem[:Scope] || elem[:Headers]
105
+
106
+ vs << Violation.new(
107
+ rule_id: "tag-deep-4",
108
+ message: "/TH without /Scope or /Headers",
109
+ object: "StructElem/TH",
110
+ severity: :warning,
111
+ spec_clause: "ISO 14289-1 7.5"
112
+ )
113
+ end
114
+ vs.empty? ? nil : vs
115
+ }
116
+ ))
117
+ end
118
+
119
+ def validate(document)
120
+ RULESET.validate(document)
121
+ end
122
+ end
123
+ end
124
+ end
125
+ # rubocop:enable Metrics/BlockLength