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,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/canvas.rb
CHANGED
|
@@ -68,6 +68,122 @@ module Pdfrb
|
|
|
68
68
|
move_to(x1, y1).line_to(x2, y2)
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
# Draw a connected polyline through +points+. Each point is
|
|
72
|
+
# [x, y]. If +close+ is true, append a close-path operator.
|
|
73
|
+
def polyline(points, close: false)
|
|
74
|
+
return self if points.empty?
|
|
75
|
+
|
|
76
|
+
first = points.first
|
|
77
|
+
move_to(first[0], first[1])
|
|
78
|
+
points.drop(1).each { |x, y| line_to(x, y) }
|
|
79
|
+
close_path if close
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Draw a closed polygon through +points+.
|
|
84
|
+
def polygon(points)
|
|
85
|
+
polyline(points, close: true)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Approximate a circular arc from +start_angle+ to +end_angle+
|
|
89
|
+
# (radians) centered at (cx, cy) with +radius+. Uses 16-line
|
|
90
|
+
# segment approximation per full revolution.
|
|
91
|
+
def arc(cx, cy, radius, start_angle:, end_angle:, segments_per_rev: 16)
|
|
92
|
+
span = end_angle - start_angle
|
|
93
|
+
return self if span.abs < 1e-6
|
|
94
|
+
|
|
95
|
+
seg_count = [(segments_per_rev * span.abs / (2 * Math::PI)).ceil, 1].max
|
|
96
|
+
step = span / seg_count
|
|
97
|
+
seg_count.times do |i|
|
|
98
|
+
a0 = start_angle + (i * step)
|
|
99
|
+
a1 = a0 + step
|
|
100
|
+
x0 = cx + (radius * Math.cos(a0))
|
|
101
|
+
y0 = cy + (radius * Math.sin(a0))
|
|
102
|
+
x1 = cx + (radius * Math.cos(a1))
|
|
103
|
+
y1 = cy + (radius * Math.sin(a1))
|
|
104
|
+
i.zero? ? move_to(x0, y0) : line_to(x0, y0)
|
|
105
|
+
line_to(x1, y1)
|
|
106
|
+
end
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Full circle centered at (cx, cy) with +radius+.
|
|
111
|
+
def circle(cx, cy, radius)
|
|
112
|
+
arc(cx, cy, radius, start_angle: 0, end_angle: 2 * Math::PI)
|
|
113
|
+
close_path
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Ellipse centered at (cx, cy) with x/y radii (rx, ry).
|
|
117
|
+
# Built from a scaled circle.
|
|
118
|
+
def ellipse(cx, cy, rx, ry)
|
|
119
|
+
save_graphics_state do
|
|
120
|
+
translate(cx, cy)
|
|
121
|
+
concat(1, 0, 0, 1, 0, 0)
|
|
122
|
+
# Approximate ellipse by scaling a unit circle.
|
|
123
|
+
segments = 32
|
|
124
|
+
segments.times do |i|
|
|
125
|
+
a0 = (i / segments.to_f) * 2 * Math::PI
|
|
126
|
+
a1 = ((i + 1) / segments.to_f) * 2 * Math::PI
|
|
127
|
+
x0 = rx * Math.cos(a0)
|
|
128
|
+
y0 = ry * Math.sin(a0)
|
|
129
|
+
x1 = rx * Math.cos(a1)
|
|
130
|
+
y1 = ry * Math.sin(a1)
|
|
131
|
+
i.zero? ? move_to(x0, y0) : line_to(x0, y0)
|
|
132
|
+
line_to(x1, y1)
|
|
133
|
+
end
|
|
134
|
+
close_path
|
|
135
|
+
end
|
|
136
|
+
self
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Rectangle with rounded corners. +radius+ is the corner radius
|
|
140
|
+
# (a single value or 4-element [tl, tr, br, bl]).
|
|
141
|
+
def rounded_rectangle(x, y, width, height, radius)
|
|
142
|
+
radii = radius.is_a?(::Array) ? radius : [radius] * 4
|
|
143
|
+
tl, tr, br, bl = radii
|
|
144
|
+
# bottom edge
|
|
145
|
+
move_to(x + bl, y)
|
|
146
|
+
line_to(x + width - br, y)
|
|
147
|
+
corner(x + width - br, y, x + width, y + br)
|
|
148
|
+
line_to(x + width, y + height - tr)
|
|
149
|
+
corner(x + width, y + height - tr,
|
|
150
|
+
x + width - tr, y + height)
|
|
151
|
+
line_to(x + tl, y + height)
|
|
152
|
+
corner(x + tl, y + height, x, y + height - tl)
|
|
153
|
+
line_to(x, y + bl)
|
|
154
|
+
corner(x, y + bl, x + bl, y)
|
|
155
|
+
close_path
|
|
156
|
+
self
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Convenience alias matching HexaPDF's naming. Accepts either
|
|
160
|
+
# an Array (interpreted as the dash array, phase 0) or an
|
|
161
|
+
# [array, phase] pair.
|
|
162
|
+
def dash=(spec)
|
|
163
|
+
array, phase = case spec
|
|
164
|
+
when ::Array
|
|
165
|
+
if spec.first.is_a?(::Array)
|
|
166
|
+
spec
|
|
167
|
+
else
|
|
168
|
+
[spec, 0]
|
|
169
|
+
end
|
|
170
|
+
else
|
|
171
|
+
[[spec], 0]
|
|
172
|
+
end
|
|
173
|
+
emit_op(Pdfrb::Content::Operator::DashPattern, array, phase)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Draw a quarter-circle corner from (x0, y0) to (x1, y1) by
|
|
177
|
+
# multi-segment linear interpolation. Adequate for typical
|
|
178
|
+
# radii (< 30 pts); for larger radii use curve_to directly.
|
|
179
|
+
def corner(x0, y0, x1, y1)
|
|
180
|
+
segments = 6
|
|
181
|
+
segments.times do |i|
|
|
182
|
+
t = (i + 1) / segments.to_f
|
|
183
|
+
line_to(x0 + ((x1 - x0) * t), y0 + ((y1 - y0) * t))
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
71
187
|
def curve_to(c1x, c1y, c2x, c2y, x, y)
|
|
72
188
|
emit_op(Pdfrb::Content::Operator::CurveTo, c1x, c1y, c2x, c2y, x, y)
|
|
73
189
|
self
|
|
@@ -214,6 +330,27 @@ module Pdfrb
|
|
|
214
330
|
self
|
|
215
331
|
end
|
|
216
332
|
|
|
333
|
+
# Emit an inline image (BI ... ID ... EI) directly into the
|
|
334
|
+
# content stream. Avoids the /Resources /XObject registration
|
|
335
|
+
# overhead for one-shot images. The +dict+ keys are the image
|
|
336
|
+
# header keys (/W, /H, /CS, /BPC, /F, etc.); +data+ is the
|
|
337
|
+
# encoded image bytes (raw, Flate-compressed, or DCT).
|
|
338
|
+
#
|
|
339
|
+
# @param dict [Hash{Symbol=>Object}] inline image header entries
|
|
340
|
+
# (e.g. { W: 100, H: 100, CS: :RGB, BPC: 8, F: :DCTDecode }).
|
|
341
|
+
# @param data [String] raw image bytes (binary-encoded).
|
|
342
|
+
def inline_image(dict:, data:)
|
|
343
|
+
header = +"BI\n"
|
|
344
|
+
dict.each do |k, v|
|
|
345
|
+
header << @serializer.serialize(k) << " " << @serializer.serialize(v) << "\n"
|
|
346
|
+
end
|
|
347
|
+
header << "ID\n"
|
|
348
|
+
append(header)
|
|
349
|
+
append(data)
|
|
350
|
+
append("\nEI\n")
|
|
351
|
+
self
|
|
352
|
+
end
|
|
353
|
+
|
|
217
354
|
def text(str, at:, font:, size:, char_spacing: nil, word_spacing: nil)
|
|
218
355
|
@used_fonts[font] = size
|
|
219
356
|
encoded = encode_for_font(str.to_s, font)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pdfrb
|
|
4
|
+
module Content
|
|
5
|
+
module Operator
|
|
6
|
+
# Inline image operators (s8.9.7). Inline images embed raw image
|
|
7
|
+
# data directly in the content stream, framed by BI ... ID ...
|
|
8
|
+
# EI. They're an alternative to /XObject /Image for one-shot
|
|
9
|
+
# images: smaller overhead per image, no /Resources entry
|
|
10
|
+
# needed, but the bytes live in the content stream so they
|
|
11
|
+
# can't be reused.
|
|
12
|
+
#
|
|
13
|
+
# The serialise path emits the BI dict, ID marker, raw bytes,
|
|
14
|
+
# and EI marker. The invoke path is intentionally a no-op; the
|
|
15
|
+
# content Parser handles BI/ID/EI as a single bounded block
|
|
16
|
+
# rather than via the operator dispatch table.
|
|
17
|
+
class BeginInlineImage < Base
|
|
18
|
+
class << self
|
|
19
|
+
def name; "BI"; end
|
|
20
|
+
|
|
21
|
+
def serialize(serializer, **dict)
|
|
22
|
+
buf = +"BI\n"
|
|
23
|
+
dict.each do |k, v|
|
|
24
|
+
buf << serializer.serialize(k) << " " << serializer.serialize(v) << "\n"
|
|
25
|
+
end
|
|
26
|
+
buf << "ID\n"
|
|
27
|
+
buf
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def invoke(_processor, *_operands); end
|
|
31
|
+
register
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class EndInlineImage < Base
|
|
36
|
+
class << self
|
|
37
|
+
def name; "EI"; end
|
|
38
|
+
|
|
39
|
+
# The byte payload sits between ID and EI. The serializer
|
|
40
|
+
# for inline images writes the payload directly via
|
|
41
|
+
# Canvas#inline_image; this method is here so the registry
|
|
42
|
+
# knows about EI.
|
|
43
|
+
def serialize(_serializer, *_operands)
|
|
44
|
+
"EI\n"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def invoke(_processor, *_operands); end
|
|
48
|
+
register
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -16,6 +16,7 @@ module Pdfrb
|
|
|
16
16
|
autoload :GraphicsStateParams, "pdfrb/content/operators/graphics_state_params"
|
|
17
17
|
autoload :MarkedContent, "pdfrb/content/operators/marked_content"
|
|
18
18
|
autoload :Clipping, "pdfrb/content/operators/clipping"
|
|
19
|
+
autoload :InlineImage, "pdfrb/content/operators/inline_image"
|
|
19
20
|
end
|
|
20
21
|
end
|
|
21
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
|