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,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/BlockLength
|
|
4
|
+
module Pdfrb
|
|
5
|
+
module Conformance
|
|
6
|
+
# PDF/VT conformance per ISO 16612-2. PDF/VT is the variable
|
|
7
|
+
# data printing standard for transactional and direct mail
|
|
8
|
+
# workflows. Two conformance levels:
|
|
9
|
+
#
|
|
10
|
+
# PDF/VT-1: rooted in PDF/X-4 (production-ready print environment)
|
|
11
|
+
# PDF/VT-2: rooted in PDF/X-4 but allows external references
|
|
12
|
+
#
|
|
13
|
+
# Both levels require:
|
|
14
|
+
# * /MarkInfo /Marked true (tagged)
|
|
15
|
+
# * /OutputIntents present
|
|
16
|
+
# * /DPartRoot dict on the Catalog (Document Part registry)
|
|
17
|
+
# * All fonts embedded
|
|
18
|
+
# * No encryption
|
|
19
|
+
module PdfVT
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
SHARED = RuleSet.new("PDF/VT-shared").tap do |rs|
|
|
23
|
+
rs.register(Rule.new(
|
|
24
|
+
id: "vt-1",
|
|
25
|
+
description: "/MarkInfo /Marked true required",
|
|
26
|
+
severity: :error,
|
|
27
|
+
spec_clause: "ISO 16612-2 6.2.1",
|
|
28
|
+
check: ->(doc) {
|
|
29
|
+
mark = doc.catalog[:MarkInfo]
|
|
30
|
+
mark = doc.object(mark) if mark.is_a?(Pdfrb::Model::Reference)
|
|
31
|
+
next nil if mark && mark[:Marked] == true
|
|
32
|
+
|
|
33
|
+
Violation.new(
|
|
34
|
+
rule_id: "vt-1",
|
|
35
|
+
message: "PDF/VT requires /MarkInfo /Marked true",
|
|
36
|
+
object: "Catalog/MarkInfo",
|
|
37
|
+
severity: :error,
|
|
38
|
+
spec_clause: "ISO 16612-2 6.2.1"
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
))
|
|
42
|
+
|
|
43
|
+
rs.register(Rule.new(
|
|
44
|
+
id: "vt-2",
|
|
45
|
+
description: "OutputIntents required",
|
|
46
|
+
severity: :error,
|
|
47
|
+
spec_clause: "ISO 16612-2 6.2.2",
|
|
48
|
+
check: ->(doc) {
|
|
49
|
+
next nil if doc.catalog[:OutputIntents]
|
|
50
|
+
|
|
51
|
+
Violation.new(
|
|
52
|
+
rule_id: "vt-2",
|
|
53
|
+
message: "PDF/VT requires /OutputIntents",
|
|
54
|
+
object: "Catalog/OutputIntents",
|
|
55
|
+
severity: :error,
|
|
56
|
+
spec_clause: "ISO 16612-2 6.2.2"
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
))
|
|
60
|
+
|
|
61
|
+
rs.register(Rule.new(
|
|
62
|
+
id: "vt-3",
|
|
63
|
+
description: "/DPartRoot required for variable data partitioning",
|
|
64
|
+
severity: :error,
|
|
65
|
+
spec_clause: "ISO 16612-2 6.3",
|
|
66
|
+
check: ->(doc) {
|
|
67
|
+
next nil if doc.catalog[:DPartRoot]
|
|
68
|
+
|
|
69
|
+
Violation.new(
|
|
70
|
+
rule_id: "vt-3",
|
|
71
|
+
message: "PDF/VT requires /DPartRoot on Catalog",
|
|
72
|
+
object: "Catalog/DPartRoot",
|
|
73
|
+
severity: :error,
|
|
74
|
+
spec_clause: "ISO 16612-2 6.3"
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
))
|
|
78
|
+
|
|
79
|
+
rs.register(Rule.new(
|
|
80
|
+
id: "vt-4",
|
|
81
|
+
description: "Encryption is forbidden",
|
|
82
|
+
severity: :error,
|
|
83
|
+
spec_clause: "ISO 16612-2 6.2.4",
|
|
84
|
+
check: ->(doc) {
|
|
85
|
+
next nil unless doc.trailer && doc.trailer[:Encrypt]
|
|
86
|
+
|
|
87
|
+
Violation.new(
|
|
88
|
+
rule_id: "vt-4",
|
|
89
|
+
message: "PDF/VT prohibits encryption",
|
|
90
|
+
object: "Trailer/Encrypt",
|
|
91
|
+
severity: :error,
|
|
92
|
+
spec_clause: "ISO 16612-2 6.2.4"
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
VT1 = RuleSet.new("PDF/VT-1").tap do |rs|
|
|
99
|
+
SHARED.rules.each { |r| rs.register(r) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
VT2 = RuleSet.new("PDF/VT-2").tap do |rs|
|
|
103
|
+
SHARED.rules.each { |r| rs.register(r) }
|
|
104
|
+
rs.register(Rule.new(
|
|
105
|
+
id: "vt2-1",
|
|
106
|
+
description: "PDF/VT-2 permits external OutputIntents references",
|
|
107
|
+
severity: :warning,
|
|
108
|
+
spec_clause: "ISO 16612-2 6.4.2",
|
|
109
|
+
check: ->(_doc) {}
|
|
110
|
+
))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
LEVEL_RULESETS = {
|
|
114
|
+
vt1: VT1, vt2: VT2
|
|
115
|
+
}.freeze
|
|
116
|
+
|
|
117
|
+
def profiles
|
|
118
|
+
LEVEL_RULESETS.dup
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def validate(document, level: :vt1)
|
|
122
|
+
rs = LEVEL_RULESETS[level] || SHARED
|
|
123
|
+
rs.validate(document)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
# rubocop:enable Metrics/BlockLength
|
|
@@ -21,7 +21,7 @@ module Pdfrb
|
|
|
21
21
|
module_function
|
|
22
22
|
|
|
23
23
|
def profiles
|
|
24
|
-
{ x1a: X1A, x3: X3, x4: X4 }
|
|
24
|
+
{ x1a: X1A, x3: X3, x4: X4, x6: X6 }
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def validate(document, level: :x4)
|
|
@@ -32,6 +32,7 @@ module Pdfrb
|
|
|
32
32
|
case level.to_s
|
|
33
33
|
when /\Ax1/i then X1A
|
|
34
34
|
when /\Ax3/i then X3
|
|
35
|
+
when /\Ax6/i then X6
|
|
35
36
|
else X4
|
|
36
37
|
end
|
|
37
38
|
end
|
|
@@ -221,6 +222,70 @@ module Pdfrb
|
|
|
221
222
|
SHARED.rules.each { |r| rs.register(r) }
|
|
222
223
|
end
|
|
223
224
|
|
|
225
|
+
# X-6 specific rules. Defined as lambdas (not module methods)
|
|
226
|
+
# so they are in scope when X6 is built.
|
|
227
|
+
X6_VERSION_RULE = Rule.new(
|
|
228
|
+
id: "x6-1",
|
|
229
|
+
description: "PDF/X-6 requires PDF 2.0",
|
|
230
|
+
severity: :error,
|
|
231
|
+
spec_clause: "ISO 15930-11 6.1",
|
|
232
|
+
check: ->(doc) {
|
|
233
|
+
v = doc.version.to_s
|
|
234
|
+
next nil if compare_versions(v, "2.0") >= 0
|
|
235
|
+
|
|
236
|
+
Violation.new(
|
|
237
|
+
rule_id: "x6-1",
|
|
238
|
+
message: "PDF/X-6 requires PDF version 2.0 (was #{v})",
|
|
239
|
+
object: "Header",
|
|
240
|
+
severity: :error,
|
|
241
|
+
spec_clause: "ISO 15930-11 6.1"
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
X6_OUTPUT_INTENT_RULE = Rule.new(
|
|
247
|
+
id: "x6-2",
|
|
248
|
+
description: "PDF/X-6 OutputIntents should use /S /GTS_PDFX",
|
|
249
|
+
severity: :warning,
|
|
250
|
+
spec_clause: "ISO 15930-11 6.2.4",
|
|
251
|
+
check: ->(doc) {
|
|
252
|
+
intents = doc.catalog[:OutputIntents]
|
|
253
|
+
intents_array = case intents
|
|
254
|
+
when ::Array then intents
|
|
255
|
+
when Pdfrb::Model::PdfArray then intents.value
|
|
256
|
+
when nil then []
|
|
257
|
+
else [intents]
|
|
258
|
+
end
|
|
259
|
+
has_pdfx = intents_array.any? do |i|
|
|
260
|
+
obj = i.is_a?(Pdfrb::Model::Reference) ? doc.object(i) : i
|
|
261
|
+
obj && obj[:S] == :GTS_PDFX
|
|
262
|
+
end
|
|
263
|
+
next nil if has_pdfx
|
|
264
|
+
|
|
265
|
+
Violation.new(
|
|
266
|
+
rule_id: "x6-2",
|
|
267
|
+
message: "PDF/X-6 OutputIntents should include an entry with /S /GTS_PDFX",
|
|
268
|
+
object: "Catalog/OutputIntents",
|
|
269
|
+
severity: :warning,
|
|
270
|
+
spec_clause: "ISO 15930-11 6.2.4"
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# X-6: ISO 15930-11 — PDF 2.0 based. Adds the PDF 2.0 base
|
|
276
|
+
# version requirement and PDF/X-4's relaxed rules.
|
|
277
|
+
X6 = RuleSet.new("PDF/X-6").tap do |rs|
|
|
278
|
+
SHARED.rules.each { |r| rs.register(r) }
|
|
279
|
+
rs.register(X6_VERSION_RULE)
|
|
280
|
+
rs.register(X6_OUTPUT_INTENT_RULE)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def compare_versions(a, b)
|
|
284
|
+
aa = a.to_s.split(".").map(&:to_i)
|
|
285
|
+
bb = b.to_s.split(".").map(&:to_i)
|
|
286
|
+
(aa <=> bb) || 0
|
|
287
|
+
end
|
|
288
|
+
|
|
224
289
|
def scan_color_spaces(doc, &block)
|
|
225
290
|
doc.each_indirect_object do |obj|
|
|
226
291
|
next unless obj.is_a?(Pdfrb::Model::Cos::Dictionary)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/BlockLength
|
|
4
|
+
module Pdfrb
|
|
5
|
+
module Conformance
|
|
6
|
+
# Tagged PDF validation per ISO 32000-1 §14.8 (and ISO 32000-2
|
|
7
|
+
# §14.8). Tagged PDF is the foundation of PDF/UA and PDF/A-2a/3a;
|
|
8
|
+
# this rule set checks the structural requirements that any
|
|
9
|
+
# tagged PDF must satisfy, regardless of higher-level profile.
|
|
10
|
+
#
|
|
11
|
+
# This is intentionally a subset of PDF/UA. Use PdfUA for the
|
|
12
|
+
# full accessibility profile; use TaggedPdf for a baseline
|
|
13
|
+
# structural pass that doesn't require accessibility annotations
|
|
14
|
+
# (alt text, reading order).
|
|
15
|
+
module TaggedPdf
|
|
16
|
+
STANDARD_STRUCTURE_TYPES = %i[
|
|
17
|
+
Document Part Div Art Sect BlockQuote Caption TOC TOCI
|
|
18
|
+
Index NonStruct Private H H1 H2 H3 H4 H5 H6 P L LI Lbl LBody
|
|
19
|
+
Table TR TH TD THead TBody TFoot Caption Span Quote Note
|
|
20
|
+
Reference BibEntry Code Figure Formula Form
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
RULESET = RuleSet.new("TaggedPDF").tap do |rs|
|
|
26
|
+
rs.register(Rule.new(
|
|
27
|
+
id: "tag-1",
|
|
28
|
+
description: "/MarkInfo /Marked must be true",
|
|
29
|
+
severity: :error,
|
|
30
|
+
spec_clause: "ISO 32000-2 14.8.1",
|
|
31
|
+
check: ->(doc) {
|
|
32
|
+
mark_info = doc.catalog[:MarkInfo]
|
|
33
|
+
mark_info = doc.object(mark_info) if mark_info.is_a?(Pdfrb::Model::Reference)
|
|
34
|
+
next nil if mark_info && mark_info[:Marked] == true
|
|
35
|
+
|
|
36
|
+
Violation.new(
|
|
37
|
+
rule_id: "tag-1",
|
|
38
|
+
message: "/MarkInfo /Marked true is required for tagged PDF",
|
|
39
|
+
object: "Catalog",
|
|
40
|
+
severity: :error,
|
|
41
|
+
spec_clause: "ISO 32000-2 14.8.1"
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
))
|
|
45
|
+
|
|
46
|
+
rs.register(Rule.new(
|
|
47
|
+
id: "tag-2",
|
|
48
|
+
description: "/StructTreeRoot must be present",
|
|
49
|
+
severity: :error,
|
|
50
|
+
spec_clause: "ISO 32000-2 14.7.2",
|
|
51
|
+
check: ->(doc) {
|
|
52
|
+
next nil if doc.catalog[:StructTreeRoot]
|
|
53
|
+
|
|
54
|
+
Violation.new(
|
|
55
|
+
rule_id: "tag-2",
|
|
56
|
+
message: "/StructTreeRoot required for tagged PDF",
|
|
57
|
+
object: "Catalog",
|
|
58
|
+
severity: :error,
|
|
59
|
+
spec_clause: "ISO 32000-2 14.7.2"
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
))
|
|
63
|
+
|
|
64
|
+
rs.register(Rule.new(
|
|
65
|
+
id: "tag-3",
|
|
66
|
+
description: "Structure elements must use standard types",
|
|
67
|
+
severity: :warning,
|
|
68
|
+
spec_clause: "ISO 32000-2 14.8.4",
|
|
69
|
+
check: ->(doc) {
|
|
70
|
+
violations = []
|
|
71
|
+
walk_structure(doc) do |elem|
|
|
72
|
+
s = elem[:S]
|
|
73
|
+
next if s.nil? || STANDARD_STRUCTURE_TYPES.include?(s.to_sym)
|
|
74
|
+
|
|
75
|
+
violations << Violation.new(
|
|
76
|
+
rule_id: "tag-3",
|
|
77
|
+
message: "Non-standard structure type '#{s}' on element",
|
|
78
|
+
object: "StructElem",
|
|
79
|
+
severity: :warning,
|
|
80
|
+
spec_clause: "ISO 32000-2 14.8.4"
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
violations
|
|
84
|
+
}
|
|
85
|
+
))
|
|
86
|
+
|
|
87
|
+
rs.register(Rule.new(
|
|
88
|
+
id: "tag-4",
|
|
89
|
+
description: "Figure elements should have /Alt or /ActualText",
|
|
90
|
+
severity: :warning,
|
|
91
|
+
spec_clause: "ISO 32000-2 14.9.2",
|
|
92
|
+
check: ->(doc) {
|
|
93
|
+
violations = []
|
|
94
|
+
walk_structure(doc) do |elem|
|
|
95
|
+
next unless elem[:S] == :Figure
|
|
96
|
+
next if elem[:Alt] || elem[:ActualText]
|
|
97
|
+
|
|
98
|
+
violations << Violation.new(
|
|
99
|
+
rule_id: "tag-4",
|
|
100
|
+
message: "Figure without /Alt or /ActualText",
|
|
101
|
+
object: "StructElem/Figure",
|
|
102
|
+
severity: :warning,
|
|
103
|
+
spec_clause: "ISO 32000-2 14.9.2"
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
violations
|
|
107
|
+
}
|
|
108
|
+
))
|
|
109
|
+
|
|
110
|
+
rs.register(Rule.new(
|
|
111
|
+
id: "tag-5",
|
|
112
|
+
description: "Table structure should include rows",
|
|
113
|
+
severity: :warning,
|
|
114
|
+
spec_clause: "ISO 32000-2 14.8.5",
|
|
115
|
+
check: ->(doc) {
|
|
116
|
+
violations = []
|
|
117
|
+
walk_structure(doc) do |elem|
|
|
118
|
+
next unless elem[:S] == :Table
|
|
119
|
+
|
|
120
|
+
kids = structure_kids(elem, doc)
|
|
121
|
+
has_tr = kids.any? { |k| k[:S] == :TR }
|
|
122
|
+
next if has_tr
|
|
123
|
+
|
|
124
|
+
violations << Violation.new(
|
|
125
|
+
rule_id: "tag-5",
|
|
126
|
+
message: "Table has no TR child rows",
|
|
127
|
+
object: "StructElem/Table",
|
|
128
|
+
severity: :warning,
|
|
129
|
+
spec_clause: "ISO 32000-2 14.8.5"
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
violations
|
|
133
|
+
}
|
|
134
|
+
))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def validate(document)
|
|
138
|
+
RULESET.validate(document)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Walk the structure tree starting at /StructTreeRoot, yielding
|
|
142
|
+
# each element dict. Silently skips cycles and missing refs.
|
|
143
|
+
def walk_structure(document)
|
|
144
|
+
seen = ::Set.new
|
|
145
|
+
root_ref = document.catalog[:StructTreeRoot]
|
|
146
|
+
return unless root_ref
|
|
147
|
+
|
|
148
|
+
root = root_ref.is_a?(Pdfrb::Model::Reference) ? document.object(root_ref) : root_ref
|
|
149
|
+
return unless root
|
|
150
|
+
|
|
151
|
+
first_kids = structure_kids(root, document)
|
|
152
|
+
queue = first_kids.dup
|
|
153
|
+
until queue.empty?
|
|
154
|
+
elem = queue.shift
|
|
155
|
+
next unless elem
|
|
156
|
+
next if seen.include?(elem.object_id)
|
|
157
|
+
|
|
158
|
+
seen << elem.object_id
|
|
159
|
+
yield elem
|
|
160
|
+
queue.concat(structure_kids(elem, document))
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def structure_kids(element, document)
|
|
165
|
+
kids = element[:K]
|
|
166
|
+
return [] unless kids
|
|
167
|
+
|
|
168
|
+
kids = kids.value if kids.is_a?(Pdfrb::Model::PdfArray)
|
|
169
|
+
kids = [kids] unless kids.is_a?(::Array)
|
|
170
|
+
kids.filter_map do |k|
|
|
171
|
+
next nil unless k.is_a?(Pdfrb::Model::Reference)
|
|
172
|
+
|
|
173
|
+
obj = document.object(k)
|
|
174
|
+
next nil unless obj.is_a?(Pdfrb::Model::Cos::Dictionary)
|
|
175
|
+
|
|
176
|
+
obj
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
# rubocop:enable Metrics/BlockLength
|
data/lib/pdfrb/conformance.rb
CHANGED
|
@@ -7,8 +7,16 @@ module Pdfrb
|
|
|
7
7
|
autoload :Violation, "pdfrb/conformance/rule"
|
|
8
8
|
autoload :ValidationResult, "pdfrb/conformance/rule"
|
|
9
9
|
autoload :PdfA, "pdfrb/conformance/pdf_a"
|
|
10
|
+
autoload :PdfA4Deep, "pdfrb/conformance/pdf_a4_deep"
|
|
10
11
|
autoload :PdfUA, "pdfrb/conformance/pdf_ua"
|
|
12
|
+
autoload :PdfUA2Deep, "pdfrb/conformance/pdf_ua2_deep"
|
|
13
|
+
autoload :PdfUATaggingDeep, "pdfrb/conformance/pdf_ua_tagging_deep"
|
|
11
14
|
autoload :PdfX, "pdfrb/conformance/pdf_x"
|
|
15
|
+
autoload :PdfVT, "pdfrb/conformance/pdf_vt"
|
|
16
|
+
autoload :Pdf2AF, "pdfrb/conformance/pdf_2_af"
|
|
17
|
+
autoload :Pades, "pdfrb/conformance/pades"
|
|
18
|
+
autoload :Ltv, "pdfrb/conformance/ltv"
|
|
19
|
+
autoload :TaggedPdf, "pdfrb/conformance/tagged_pdf"
|
|
12
20
|
autoload :StructureElements, "pdfrb/conformance/structure_elements"
|
|
13
21
|
autoload :VeraPdfBridge, "pdfrb/conformance/verapdf_bridge"
|
|
14
22
|
end
|
data/lib/pdfrb/content/parser.rb
CHANGED
|
@@ -23,11 +23,21 @@ module Pdfrb
|
|
|
23
23
|
|
|
24
24
|
# Yields (operator_class, operands) pairs. Returns an Enumerator
|
|
25
25
|
# if no block.
|
|
26
|
+
#
|
|
27
|
+
# Special-cases the BI/ID/EI inline image sequence: when BI is
|
|
28
|
+
# seen, the inline image dict + raw byte payload are read as a
|
|
29
|
+
# single InlineImage invocation rather than as discrete tokens.
|
|
26
30
|
def each_invocation
|
|
27
31
|
return enum_for(:each_invocation) unless block_given?
|
|
28
32
|
|
|
29
33
|
operands = []
|
|
30
34
|
while (tok = tokenizer.next_token)
|
|
35
|
+
if tok.type == :keyword && tok.value == "BI"
|
|
36
|
+
yield Pdfrb::Content::Operator::BeginInlineImage, [parse_inline_image]
|
|
37
|
+
operands = []
|
|
38
|
+
next
|
|
39
|
+
end
|
|
40
|
+
|
|
31
41
|
case tok.type
|
|
32
42
|
when :keyword
|
|
33
43
|
op = Pdfrb::Content::Operator[tok.value]
|
|
@@ -48,6 +58,78 @@ module Pdfrb
|
|
|
48
58
|
self
|
|
49
59
|
end
|
|
50
60
|
|
|
61
|
+
# Parse a BI ... ID <bytes> EI inline image sequence. The BI
|
|
62
|
+
# keyword has already been consumed; the next tokens form the
|
|
63
|
+
# image header (key/value pairs), then ID introduces the raw
|
|
64
|
+
# byte payload terminated by EI.
|
|
65
|
+
#
|
|
66
|
+
# Returns a Hash with :header (the key-value pairs) and
|
|
67
|
+
# :data (the raw image bytes).
|
|
68
|
+
def parse_inline_image
|
|
69
|
+
header = {}
|
|
70
|
+
# Read header pairs until we hit the ID keyword.
|
|
71
|
+
while (tok = tokenizer.next_token)
|
|
72
|
+
break if tok.type == :keyword && tok.value == "ID"
|
|
73
|
+
|
|
74
|
+
if tok.type == :name
|
|
75
|
+
key = abbrev_for(tok.value) || tok.value.to_sym
|
|
76
|
+
val_tok = tokenizer.next_token
|
|
77
|
+
header[key] = token_value(val_tok)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# After ID, exactly one whitespace byte separates the
|
|
82
|
+
# keyword from the data. Read raw bytes until "\nEI" or
|
|
83
|
+
# " EI" terminator.
|
|
84
|
+
data = read_inline_image_data
|
|
85
|
+
{ header: header, data: data }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Map inline-image abbreviation keys to full PDF names per
|
|
89
|
+
# ISO 32000-2 §8.9.7 Table 89.
|
|
90
|
+
def abbrev_for(name)
|
|
91
|
+
ABBREV_TABLE[name.to_sym]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
ABBREV_TABLE = {
|
|
95
|
+
BPC: :BitsPerComponent,
|
|
96
|
+
CS: :ColorSpace,
|
|
97
|
+
D: :Decode,
|
|
98
|
+
DP: :DecodeParms,
|
|
99
|
+
F: :Filter,
|
|
100
|
+
H: :Height,
|
|
101
|
+
IM: :ImageMask,
|
|
102
|
+
Intent: :Intent,
|
|
103
|
+
I: :Interpolate,
|
|
104
|
+
W: :Width,
|
|
105
|
+
}.freeze
|
|
106
|
+
|
|
107
|
+
def read_inline_image_data
|
|
108
|
+
# Skip exactly one whitespace byte after ID.
|
|
109
|
+
tokenizer.skip_whitespace
|
|
110
|
+
bytes = +"".b
|
|
111
|
+
# Read until we see the "EI" marker. The marker is usually
|
|
112
|
+
# preceded by whitespace and followed by whitespace or EOF.
|
|
113
|
+
until tokenizer.eof?
|
|
114
|
+
b = tokenizer.read_byte
|
|
115
|
+
break if b.nil?
|
|
116
|
+
|
|
117
|
+
bytes << b
|
|
118
|
+
if bytes.bytesize >= 3 &&
|
|
119
|
+
whitespace_byte?(bytes.getbyte(-3)) &&
|
|
120
|
+
bytes.byteslice(-2, 2) == "EI"
|
|
121
|
+
return bytes.byteslice(0, bytes.bytesize - 3).b
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
bytes.b
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
WHITESPACE_BYTE_VALUES = [0, 9, 10, 12, 13, 32].freeze
|
|
128
|
+
|
|
129
|
+
def whitespace_byte?(byte)
|
|
130
|
+
WHITESPACE_BYTE_VALUES.include?(byte)
|
|
131
|
+
end
|
|
132
|
+
|
|
51
133
|
private
|
|
52
134
|
|
|
53
135
|
def consume_array
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "net/https"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Pdfrb
|
|
8
|
+
module DigitalSignature
|
|
9
|
+
# RFC 3161 trusted timestamp client. Submits a hash to a Time
|
|
10
|
+
# Stamp Authority (TSA) over HTTPS and returns the resulting
|
|
11
|
+
# timestamp token (TSTInfo) as DER bytes, suitable for inclusion
|
|
12
|
+
# in a PAdES B-T/LTA signature.
|
|
13
|
+
#
|
|
14
|
+
# Pure-Ruby: uses Net::HTTP with OpenSSL. The TSA URL is
|
|
15
|
+
# configurable; defaults to a free public TSA. Callers in
|
|
16
|
+
# production should configure a trusted TSA endpoint.
|
|
17
|
+
module TimestampClient
|
|
18
|
+
DEFAULT_TSA_URL = "https://freetsa.org/tsr"
|
|
19
|
+
DEFAULT_HASH_ALGORITHM = "sha256"
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Submit +data+ (or its digest) to the TSA at +url+ and return
|
|
24
|
+
# the DER-encoded TimeStampResp.
|
|
25
|
+
#
|
|
26
|
+
# @param data [String] the bytes to timestamp.
|
|
27
|
+
# @param url [String] TSA endpoint URL.
|
|
28
|
+
# @param hash_algorithm [String] OpenSSL digest name.
|
|
29
|
+
# @param cert [OpenSSL::X509::Certificate, nil] client cert.
|
|
30
|
+
# @param key [OpenSSL::PKey::RSA, nil] client key.
|
|
31
|
+
# @return [String] DER-encoded TimeStampResp bytes.
|
|
32
|
+
def request_timestamp(data:, url: DEFAULT_TSA_URL,
|
|
33
|
+
hash_algorithm: DEFAULT_HASH_ALGORITHM,
|
|
34
|
+
cert: nil, key: nil)
|
|
35
|
+
digest = OpenSSL::Digest.new(hash_algorithm)
|
|
36
|
+
hashed = digest.digest(data)
|
|
37
|
+
req = build_tsq_request(hashed, hash_algorithm)
|
|
38
|
+
https_post(url, req, cert: cert, key: key)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
DIGEST_OIDS = {
|
|
42
|
+
"sha1" => "1.3.14.3.2.26",
|
|
43
|
+
"sha256" => "2.16.840.1.101.3.4.2.1",
|
|
44
|
+
"sha384" => "2.16.840.1.101.3.4.2.2",
|
|
45
|
+
"sha512" => "2.16.840.1.101.3.4.2.3",
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# Build the TimeStampReq DER. Pure OpenSSL::ASN1 construction.
|
|
49
|
+
def build_tsq_request(hashed_bytes, hash_algorithm)
|
|
50
|
+
oid = DIGEST_OIDS[hash_algorithm.to_s] || DIGEST_OIDS["sha256"]
|
|
51
|
+
OpenSSL::ASN1::Sequence.new([
|
|
52
|
+
OpenSSL::ASN1::Integer.new(1), # version
|
|
53
|
+
OpenSSL::ASN1::Sequence.new([ # messageImprint
|
|
54
|
+
OpenSSL::ASN1::Sequence.new([
|
|
55
|
+
OpenSSL::ASN1::ObjectId.new(oid),
|
|
56
|
+
OpenSSL::ASN1::Null.new(nil),
|
|
57
|
+
]),
|
|
58
|
+
OpenSSL::ASN1::OctetString.new(hashed_bytes),
|
|
59
|
+
]),
|
|
60
|
+
OpenSSL::ASN1::Boolean.new(true), # reqCert
|
|
61
|
+
]).to_der
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def https_post(url, body, cert: nil, key: nil)
|
|
65
|
+
uri = URI(url)
|
|
66
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
67
|
+
http.use_ssl = true if uri.is_a?(URI::HTTPS)
|
|
68
|
+
http.ssl_timeout = 30
|
|
69
|
+
|
|
70
|
+
if cert && key
|
|
71
|
+
http.cert = cert
|
|
72
|
+
http.key = key
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
req = Net::HTTP::Post.new(uri.request_uri)
|
|
76
|
+
req["Content-Type"] = "application/timestamp-query"
|
|
77
|
+
req.body = body
|
|
78
|
+
|
|
79
|
+
response = http.request(req)
|
|
80
|
+
raise "TSA error: #{response.code} #{response.message}" unless response.code == "200"
|
|
81
|
+
|
|
82
|
+
response.body
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -11,6 +11,7 @@ module Pdfrb
|
|
|
11
11
|
autoload :VerificationResult, "pdfrb/digital_signature/verification_result"
|
|
12
12
|
autoload :CmsHandler, "pdfrb/digital_signature/cms_handler"
|
|
13
13
|
autoload :TimestampHandler, "pdfrb/digital_signature/timestamp_handler"
|
|
14
|
+
autoload :TimestampClient, "pdfrb/digital_signature/timestamp_client"
|
|
14
15
|
|
|
15
16
|
HANDLERS = {
|
|
16
17
|
"adbe.pkcs7.detached": CmsHandler,
|
data/lib/pdfrb/document.rb
CHANGED
|
@@ -43,6 +43,7 @@ module Pdfrb
|
|
|
43
43
|
@object_reader = nil
|
|
44
44
|
@version = "1.4"
|
|
45
45
|
@empty_trailer = nil
|
|
46
|
+
@revisions = [] # array of [xref, trailer] tuples, latest first
|
|
46
47
|
|
|
47
48
|
if io
|
|
48
49
|
read_from_io(io)
|
|
@@ -259,6 +260,29 @@ module Pdfrb
|
|
|
259
260
|
end
|
|
260
261
|
end
|
|
261
262
|
|
|
263
|
+
# Walk every revision in the document's incremental-update chain,
|
|
264
|
+
# from the latest (most recent) revision backward via /Prev.
|
|
265
|
+
# Each revision yields a (revision_index, xref, trailer) tuple
|
|
266
|
+
# where revision_index 0 is the latest. Documents without
|
|
267
|
+
# incremental updates yield a single tuple.
|
|
268
|
+
#
|
|
269
|
+
# Useful for forensic inspection, version-aware rendering, and
|
|
270
|
+
# debugging "ghost" objects that were modified in a later
|
|
271
|
+
# revision.
|
|
272
|
+
def each_revision
|
|
273
|
+
return enum_for(:each_revision) unless block_given?
|
|
274
|
+
|
|
275
|
+
return (yield 0, @xref, trailer) if @revisions.empty? && @xref
|
|
276
|
+
|
|
277
|
+
@revisions.each_with_index { |(xref, tr), i| yield i, xref, tr }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Number of revisions in the document. 1 for a freshly written
|
|
281
|
+
# PDF; >1 for incrementally-updated PDFs.
|
|
282
|
+
def revision_count
|
|
283
|
+
@revisions.empty? ? 1 : @revisions.length
|
|
284
|
+
end
|
|
285
|
+
|
|
262
286
|
private
|
|
263
287
|
|
|
264
288
|
def allocate_oid
|
|
@@ -285,6 +309,10 @@ module Pdfrb
|
|
|
285
309
|
xref, trailer = load_single_xref(io, sxref)
|
|
286
310
|
return [nil, nil] unless xref && trailer
|
|
287
311
|
|
|
312
|
+
# Track each revision's xref + trailer so callers can walk the
|
|
313
|
+
# history (Document#each_revision).
|
|
314
|
+
@revisions = [[xref, trailer]]
|
|
315
|
+
|
|
288
316
|
# Follow /Prev chain for incremental updates. Earlier entries
|
|
289
317
|
# fill gaps; later entries take precedence (already in xref).
|
|
290
318
|
prev_offset = trailer[:Prev]
|
|
@@ -292,6 +320,7 @@ module Pdfrb
|
|
|
292
320
|
prev_xref, prev_trailer = load_single_xref(io, prev_offset)
|
|
293
321
|
break unless prev_xref
|
|
294
322
|
|
|
323
|
+
@revisions << [prev_xref, prev_trailer]
|
|
295
324
|
xref.merge!(prev_xref)
|
|
296
325
|
prev_offset = prev_trailer && prev_trailer[:Prev]
|
|
297
326
|
end
|