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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -0
- data/RELEASE.md +17 -11
- data/data/pdfrb/layout/hyphenation_en.txt +408 -0
- data/lib/pdfrb/color/default_profile.rb +213 -0
- data/lib/pdfrb/color/icc_validator.rb +69 -0
- data/lib/pdfrb/color.rb +2 -0
- data/lib/pdfrb/conformance/ltv.rb +112 -0
- data/lib/pdfrb/conformance/pades.rb +198 -0
- data/lib/pdfrb/conformance/pdf_2_af.rb +185 -0
- data/lib/pdfrb/conformance/pdf_a.rb +123 -0
- data/lib/pdfrb/conformance/pdf_a4_deep.rb +94 -0
- data/lib/pdfrb/conformance/pdf_ua2_deep.rb +93 -0
- data/lib/pdfrb/conformance/pdf_ua_tagging_deep.rb +125 -0
- data/lib/pdfrb/conformance/pdf_vt.rb +128 -0
- data/lib/pdfrb/conformance/pdf_x.rb +66 -1
- data/lib/pdfrb/conformance/tagged_pdf.rb +182 -0
- data/lib/pdfrb/conformance.rb +8 -0
- data/lib/pdfrb/content/parser.rb +82 -0
- data/lib/pdfrb/digital_signature/timestamp_client.rb +86 -0
- data/lib/pdfrb/digital_signature.rb +1 -0
- data/lib/pdfrb/document.rb +29 -0
- data/lib/pdfrb/encryption/public_key_security_handler.rb +146 -0
- data/lib/pdfrb/encryption/standard_security_handler.rb +98 -3
- data/lib/pdfrb/encryption/v5_writer.rb +108 -0
- data/lib/pdfrb/encryption.rb +3 -0
- data/lib/pdfrb/font_loader/type3.rb +65 -0
- data/lib/pdfrb/font_loader.rb +1 -0
- data/lib/pdfrb/image_loader/gif.rb +246 -0
- data/lib/pdfrb/image_loader/tiff.rb +257 -0
- data/lib/pdfrb/image_loader.rb +2 -0
- data/lib/pdfrb/layout/font_fallback.rb +203 -0
- data/lib/pdfrb/layout/hyphenation.rb +120 -0
- data/lib/pdfrb/layout/justification_kashidas.rb +72 -0
- data/lib/pdfrb/layout/multi_cell_text_layout.rb +65 -0
- data/lib/pdfrb/layout/multi_page_table_box.rb +155 -0
- data/lib/pdfrb/layout/polygon_frame.rb +106 -0
- data/lib/pdfrb/layout/table_box.rb +154 -23
- data/lib/pdfrb/layout/text_shaper.rb +129 -0
- data/lib/pdfrb/layout.rb +7 -0
- data/lib/pdfrb/source/linearization_reader.rb +76 -0
- data/lib/pdfrb/source/recovery.rb +65 -1
- data/lib/pdfrb/source/tokenizer.rb +21 -0
- data/lib/pdfrb/source.rb +1 -0
- data/lib/pdfrb/task/thumbnail.rb +133 -0
- data/lib/pdfrb/task.rb +1 -0
- data/lib/pdfrb/version.rb +1 -1
- 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
|