pdfrb 0.6.0 → 0.7.10
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 +144 -0
- data/RELEASE.md +52 -33
- data/data/pdfrb/afm/Courier-Bold.afm +342 -342
- data/data/pdfrb/afm/Courier-BoldOblique.afm +342 -342
- data/data/pdfrb/afm/Courier-Oblique.afm +342 -342
- data/data/pdfrb/afm/Courier.afm +342 -342
- data/data/pdfrb/afm/Helvetica-Bold.afm +2827 -2827
- data/data/pdfrb/afm/Helvetica-BoldOblique.afm +2827 -2827
- data/data/pdfrb/afm/Helvetica-Oblique.afm +3051 -3051
- data/data/pdfrb/afm/Helvetica.afm +3051 -3051
- data/data/pdfrb/afm/Symbol.afm +213 -213
- data/data/pdfrb/afm/Times-Bold.afm +2588 -2588
- data/data/pdfrb/afm/Times-BoldItalic.afm +2384 -2384
- data/data/pdfrb/afm/Times-Italic.afm +2667 -2667
- data/data/pdfrb/afm/Times-Roman.afm +2419 -2419
- data/data/pdfrb/afm/ZapfDingbats.afm +225 -225
- 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/canvas.rb +137 -0
- data/lib/pdfrb/content/operators/inline_image.rb +53 -0
- data/lib/pdfrb/content/operators.rb +1 -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/png.rb +76 -19
- 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/model/type/page_piece_info.rb +58 -0
- data/lib/pdfrb/model/type.rb +3 -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/regenerate_appearances.rb +98 -0
- data/lib/pdfrb/task/thumbnail.rb +133 -0
- data/lib/pdfrb/task.rb +2 -0
- data/lib/pdfrb/version.rb +1 -1
- metadata +35 -3
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/BlockLength
|
|
4
|
+
module Pdfrb
|
|
5
|
+
module Conformance
|
|
6
|
+
# Long-Term Validation (LTV) per ETSI EN 319 102-1 Annex B. LTV
|
|
7
|
+
# preserves the validity of a signature beyond the validity
|
|
8
|
+
# period of the signer's certificate by archiving revocation
|
|
9
|
+
# data and timestamping the archive over time.
|
|
10
|
+
#
|
|
11
|
+
# LTV requires:
|
|
12
|
+
# * At least one signature with /ByteRange and /Contents.
|
|
13
|
+
# * /DSS dictionary on Catalog with /Certs, /CRLs, /OCSPs as
|
|
14
|
+
# applicable.
|
|
15
|
+
# * For archival (LTA): a /DocumentTimeStamp on the catalog.
|
|
16
|
+
module Ltv
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
RULESET = RuleSet.new("LTV").tap do |rs|
|
|
20
|
+
rs.register(Rule.new(
|
|
21
|
+
id: "ltv-1",
|
|
22
|
+
description: "/DSS dictionary required for LTV",
|
|
23
|
+
severity: :error,
|
|
24
|
+
spec_clause: "ETSI EN 319 102-1 B.5",
|
|
25
|
+
check: ->(doc) {
|
|
26
|
+
next nil if doc.catalog[:DSS]
|
|
27
|
+
|
|
28
|
+
Violation.new(
|
|
29
|
+
rule_id: "ltv-1",
|
|
30
|
+
message: "LTV requires /DSS dictionary on Catalog",
|
|
31
|
+
object: "Catalog/DSS",
|
|
32
|
+
severity: :error,
|
|
33
|
+
spec_clause: "ETSI EN 319 102-1 B.5"
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
))
|
|
37
|
+
|
|
38
|
+
rs.register(Rule.new(
|
|
39
|
+
id: "ltv-2",
|
|
40
|
+
description: "/DSS must contain validation material",
|
|
41
|
+
severity: :warning,
|
|
42
|
+
spec_clause: "ETSI EN 319 102-1 B.5",
|
|
43
|
+
check: ->(doc) {
|
|
44
|
+
dss = doc.catalog[:DSS]
|
|
45
|
+
dss = doc.object(dss) if dss.is_a?(Pdfrb::Model::Reference)
|
|
46
|
+
next nil unless dss
|
|
47
|
+
|
|
48
|
+
has_material = dss[:Certs] || dss[:CRLs] || dss[:OCSPs] ||
|
|
49
|
+
dss[:VRI]
|
|
50
|
+
next nil if has_material
|
|
51
|
+
|
|
52
|
+
Violation.new(
|
|
53
|
+
rule_id: "ltv-2",
|
|
54
|
+
message: "/DSS is empty (no /Certs, /CRLs, /OCSPs, or /VRI)",
|
|
55
|
+
object: "Catalog/DSS",
|
|
56
|
+
severity: :warning,
|
|
57
|
+
spec_clause: "ETSI EN 319 102-1 B.5"
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
))
|
|
61
|
+
|
|
62
|
+
rs.register(Rule.new(
|
|
63
|
+
id: "ltv-3",
|
|
64
|
+
description: "At least one signature should be present",
|
|
65
|
+
severity: :warning,
|
|
66
|
+
spec_clause: "ETSI EN 319 102-1 B.4",
|
|
67
|
+
check: ->(doc) {
|
|
68
|
+
fields = Pdfrb::Conformance::Pades.signature_fields(doc)
|
|
69
|
+
next nil if fields.any?
|
|
70
|
+
|
|
71
|
+
Violation.new(
|
|
72
|
+
rule_id: "ltv-3",
|
|
73
|
+
message: "LTV document has no signatures to validate",
|
|
74
|
+
object: "AcroForm/Fields",
|
|
75
|
+
severity: :warning,
|
|
76
|
+
spec_clause: "ETSI EN 319 102-1 B.4"
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
))
|
|
80
|
+
|
|
81
|
+
rs.register(Rule.new(
|
|
82
|
+
id: "ltv-4",
|
|
83
|
+
description: "Document Time Stamp for archival (LTA)",
|
|
84
|
+
severity: :warning,
|
|
85
|
+
spec_clause: "ETSI EN 319 102-1 B.6",
|
|
86
|
+
check: ->(doc) {
|
|
87
|
+
fields = Pdfrb::Conformance::Pades.signature_fields(doc)
|
|
88
|
+
has_archival = fields.any? do |field|
|
|
89
|
+
v = field[:V]
|
|
90
|
+
v_obj = v.is_a?(Pdfrb::Model::Reference) ? doc.object(v) : v
|
|
91
|
+
v_obj && v_obj[:Type]&.to_sym == :DocTimeStamp
|
|
92
|
+
end
|
|
93
|
+
next nil if has_archival
|
|
94
|
+
|
|
95
|
+
Violation.new(
|
|
96
|
+
rule_id: "ltv-4",
|
|
97
|
+
message: "Long-Term Archival (LTA) recommends a Document Time Stamp",
|
|
98
|
+
object: "AcroForm/Fields",
|
|
99
|
+
severity: :warning,
|
|
100
|
+
spec_clause: "ETSI EN 319 102-1 B.6"
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate(document)
|
|
107
|
+
RULESET.validate(document)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
# rubocop:enable Metrics/BlockLength
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/BlockLength
|
|
4
|
+
module Pdfrb
|
|
5
|
+
module Conformance
|
|
6
|
+
# PAdES (ETSI EN 319 142-1) signature profile validation. Four
|
|
7
|
+
# baseline levels build on each other:
|
|
8
|
+
#
|
|
9
|
+
# B-B: Basic signatures — just the document hash + signer cert.
|
|
10
|
+
# B-T: B-B + trusted timestamp (RFC 3161) on the signature.
|
|
11
|
+
# B-LT: B-T + long-term validation material (DSS dictionary
|
|
12
|
+
# with revocation data, signer certs, TSA cert chain).
|
|
13
|
+
# B-LTA: B-LT + archival timestamp protecting the DSS itself.
|
|
14
|
+
#
|
|
15
|
+
# Each rule set checks signature dictionaries on the document's
|
|
16
|
+
# /AcroForm /Fields for the required components per level.
|
|
17
|
+
module Pades
|
|
18
|
+
ALLOWED_SIGNATURE_TYPES = %i[Sig DocTimeStamp].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
SHARED = RuleSet.new("PAdES-shared").tap do |rs|
|
|
23
|
+
rs.register(Rule.new(
|
|
24
|
+
id: "pades-1",
|
|
25
|
+
description: "At least one signature field must be present",
|
|
26
|
+
severity: :error,
|
|
27
|
+
spec_clause: "ETSI EN 319 142-1 5.2",
|
|
28
|
+
check: ->(doc) {
|
|
29
|
+
next nil if signature_fields(doc).any?
|
|
30
|
+
|
|
31
|
+
Violation.new(
|
|
32
|
+
rule_id: "pades-1",
|
|
33
|
+
message: "PAdES requires at least one signature field",
|
|
34
|
+
object: "AcroForm/Fields",
|
|
35
|
+
severity: :error,
|
|
36
|
+
spec_clause: "ETSI EN 319 142-1 5.2"
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
BB = RuleSet.new("PAdES-B-B").tap do |rs|
|
|
43
|
+
SHARED.rules.each { |r| rs.register(r) }
|
|
44
|
+
rs.register(Rule.new(
|
|
45
|
+
id: "bb-1",
|
|
46
|
+
description: "B-B: each signature must have /Type /Sig or /DocTimeStamp",
|
|
47
|
+
severity: :error,
|
|
48
|
+
spec_clause: "ETSI EN 319 142-1 5.3",
|
|
49
|
+
check: ->(doc) {
|
|
50
|
+
vs = []
|
|
51
|
+
signature_fields(doc).each do |field|
|
|
52
|
+
v = field[:V]
|
|
53
|
+
next unless v
|
|
54
|
+
|
|
55
|
+
v_obj = v.is_a?(Pdfrb::Model::Reference) ? doc.object(v) : v
|
|
56
|
+
next if v_obj && ALLOWED_SIGNATURE_TYPES.include?(v_obj[:Type]&.to_sym)
|
|
57
|
+
|
|
58
|
+
vs << Violation.new(
|
|
59
|
+
rule_id: "bb-1",
|
|
60
|
+
message: "Signature V must be /Type /Sig or /DocTimeStamp",
|
|
61
|
+
object: "Field/V",
|
|
62
|
+
severity: :error,
|
|
63
|
+
spec_clause: "ETSI EN 319 142-1 5.3"
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
vs.empty? ? nil : vs
|
|
67
|
+
}
|
|
68
|
+
))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
BT = RuleSet.new("PAdES-B-T").tap do |rs|
|
|
72
|
+
BB.rules.each { |r| rs.register(r) }
|
|
73
|
+
rs.register(Rule.new(
|
|
74
|
+
id: "bt-1",
|
|
75
|
+
description: "B-T: signature must include a trusted timestamp",
|
|
76
|
+
severity: :error,
|
|
77
|
+
spec_clause: "ETSI EN 319 142-1 5.4",
|
|
78
|
+
check: ->(doc) {
|
|
79
|
+
vs = []
|
|
80
|
+
signature_fields(doc).each do |field|
|
|
81
|
+
v = field[:V]
|
|
82
|
+
next unless v
|
|
83
|
+
|
|
84
|
+
v_obj = v.is_a?(Pdfrb::Model::Reference) ? doc.object(v) : v
|
|
85
|
+
has_timestamp = v_obj && (
|
|
86
|
+
v_obj[:Type]&.to_sym == :DocTimeStamp ||
|
|
87
|
+
(v_obj[:ByteRange] && timestamp_in_signature?(v_obj))
|
|
88
|
+
)
|
|
89
|
+
next if has_timestamp
|
|
90
|
+
|
|
91
|
+
vs << Violation.new(
|
|
92
|
+
rule_id: "bt-1",
|
|
93
|
+
message: "B-T requires a trusted timestamp (RFC 3161)",
|
|
94
|
+
object: "Field/V",
|
|
95
|
+
severity: :error,
|
|
96
|
+
spec_clause: "ETSI EN 319 142-1 5.4"
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
vs.empty? ? nil : vs
|
|
100
|
+
}
|
|
101
|
+
))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
BLT = RuleSet.new("PAdES-B-LT").tap do |rs|
|
|
105
|
+
BT.rules.each { |r| rs.register(r) }
|
|
106
|
+
rs.register(Rule.new(
|
|
107
|
+
id: "blt-1",
|
|
108
|
+
description: "B-LT: DSS dictionary required for LTV material",
|
|
109
|
+
severity: :error,
|
|
110
|
+
spec_clause: "ETSI EN 319 142-1 5.5",
|
|
111
|
+
check: ->(doc) {
|
|
112
|
+
next nil if doc.catalog[:DSS]
|
|
113
|
+
|
|
114
|
+
Violation.new(
|
|
115
|
+
rule_id: "blt-1",
|
|
116
|
+
message: "B-LT requires /DSS dictionary with validation material",
|
|
117
|
+
object: "Catalog/DSS",
|
|
118
|
+
severity: :error,
|
|
119
|
+
spec_clause: "ETSI EN 319 142-1 5.5"
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
BLTA = RuleSet.new("PAdES-B-LTA").tap do |rs|
|
|
126
|
+
BLT.rules.each { |r| rs.register(r) }
|
|
127
|
+
rs.register(Rule.new(
|
|
128
|
+
id: "blta-1",
|
|
129
|
+
description: "B-LTA: archival Document Time Stamp required",
|
|
130
|
+
severity: :error,
|
|
131
|
+
spec_clause: "ETSI EN 319 142-1 5.6",
|
|
132
|
+
check: ->(doc) {
|
|
133
|
+
# B-LTA needs a DocTimeStamp as the last signature,
|
|
134
|
+
# protecting the DSS.
|
|
135
|
+
has_archival = signature_fields(doc).any? do |field|
|
|
136
|
+
v = field[:V]
|
|
137
|
+
v_obj = v.is_a?(Pdfrb::Model::Reference) ? doc.object(v) : v
|
|
138
|
+
v_obj && v_obj[:Type]&.to_sym == :DocTimeStamp
|
|
139
|
+
end
|
|
140
|
+
next nil if has_archival
|
|
141
|
+
|
|
142
|
+
Violation.new(
|
|
143
|
+
rule_id: "blta-1",
|
|
144
|
+
message: "B-LTA requires an archival Document Time Stamp",
|
|
145
|
+
object: "AcroForm/Fields",
|
|
146
|
+
severity: :error,
|
|
147
|
+
spec_clause: "ETSI EN 319 142-1 5.6"
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
LEVEL_RULESETS = {
|
|
154
|
+
"B-B": BB, "B-T": BT, "B-LT": BLT, "B-LTA": BLTA
|
|
155
|
+
}.freeze
|
|
156
|
+
|
|
157
|
+
def profiles
|
|
158
|
+
LEVEL_RULESETS.dup
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def validate(document, level: :"B-B")
|
|
162
|
+
rs = LEVEL_RULESETS[level] || SHARED
|
|
163
|
+
rs.validate(document)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def signature_fields(document)
|
|
167
|
+
acroform = document.catalog[:AcroForm]
|
|
168
|
+
acroform = document.object(acroform) if acroform.is_a?(Pdfrb::Model::Reference)
|
|
169
|
+
return [] unless acroform
|
|
170
|
+
|
|
171
|
+
fields = acroform[:Fields]
|
|
172
|
+
fields = fields.value if fields.is_a?(Pdfrb::Model::PdfArray)
|
|
173
|
+
return [] unless fields.is_a?(::Array)
|
|
174
|
+
|
|
175
|
+
fields.filter_map do |ref|
|
|
176
|
+
obj = ref.is_a?(Pdfrb::Model::Reference) ? document.object(ref) : ref
|
|
177
|
+
next nil unless obj
|
|
178
|
+
|
|
179
|
+
obj[:FT]&.to_sym == :Sig ? obj : nil
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Heuristic: look for the standard ASN.1 timestamp OID
|
|
184
|
+
# (1.2.840.113549.1.7.2 signedData, with a TSTInfo content
|
|
185
|
+
# type). Real validation requires parsing the PKCS#7 — out of
|
|
186
|
+
# scope for this rule set, which only checks the structural
|
|
187
|
+
# presence of a ByteRange + Contents long enough to hold a
|
|
188
|
+
# timestamp token.
|
|
189
|
+
def timestamp_in_signature?(signature)
|
|
190
|
+
contents = signature[:Contents]
|
|
191
|
+
return false unless contents.is_a?(::String)
|
|
192
|
+
|
|
193
|
+
contents.bytesize > 1024
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
# rubocop:enable Metrics/BlockLength
|
|
@@ -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 = {
|