hexapdf 1.10.0 → 1.11.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c85f671cd9d2e469b7f0e8c1ea2f9beec6a09247881c2404b8c9789ba65cd03
4
- data.tar.gz: e6d53dd3da7a366f5b724448d31bc7edacae9acb65f6f4fe95e6e3c5caf27564
3
+ metadata.gz: da17a2a9dd8459e6f4624f59621c84f1b0be73f4d23347bffcdd6c534b58680e
4
+ data.tar.gz: ecdcfedc4f89722f799d04fda467acbed24bf256a1fa626623fce1d6399d6d98
5
5
  SHA512:
6
- metadata.gz: 2572d491909fa5f4574705de076a2889d7a0869f6cb3358787394b02ed00ec7e0763617515f9f2ecb50718f6b584189f88e0dad0ab612f0472b8551402331628
7
- data.tar.gz: cfe5c5efeaa319b781c566a3d1b0034bf1876ddc8100fecdb96257f68064779476f579e266e796b5a5740177b899319c1fde9e4f21b355d14054e5f157cb3e30
6
+ metadata.gz: 5a39a7635005c7ef8be1dc0a1efc21b1aca00fba3bedb80886e5d909d9fc0ebfed0f967fff6f10f96ed6b05a883fee0337fea3802bfcde3a93a644e512d0ee3b
7
+ data.tar.gz: 4e9ccdbe854d0e5fb00e7042d61c758da93c5b354982b48a20f4a6cec3a07d209dac5711d1eb9b3b6274d8662acac8851124a838bed5795cc6e40c924f4bba26
data/CHANGELOG.md CHANGED
@@ -1,3 +1,33 @@
1
+ ## 1.11.0 - 2026-09-22
2
+
3
+ ### Added
4
+
5
+ * [HexaPDF::DigitalSignature::Signatures#add_ltv_information] for adding
6
+ long term validation information to signed documents
7
+ * [HexaPDF::Type::DocumentSecurityStore] convenience methods for accessing
8
+ entries
9
+
10
+ ### Changed
11
+
12
+ * [HexaPDF::Type::AcroForm::ChoiceField#list_box_top_index] to always return a
13
+ valid index
14
+ * [HexaPDF::Task::ImportPages] to allow resizing the pages to a target media box
15
+
16
+ ### Fixed
17
+
18
+ * [HexaPDF::Content::Canvas#xobject] to correctly position the bottom-left
19
+ corner of Form XObjects in case of scaling
20
+ * Reading password from standard input for the hexapdf CLI
21
+ * Use Adobe (not PDF spec) order for link annotation's /QuadPoints value to
22
+ ensure compatibility with most major PDF viewers/software
23
+ * [HexaPDF::Type::AcroForm::AppearanceGenerator] to work for files where one or
24
+ more values are not in the set of available list box options
25
+ * [HexaPDF::Type::AcroForm::Field#embedded_widget?] to only return if the
26
+ /Subtype entry is actually /Widget
27
+ * [HexaPDF::Task::ImportPages] to take optional content groups in Form XObjects
28
+ into account
29
+
30
+
1
31
  ## 1.10.0 - 2026-08-21
2
32
 
3
33
  ### Added
@@ -410,8 +410,7 @@ module HexaPDF
410
410
  def read_from_console(prompt, noecho: false)
411
411
  IO.console.write("#{prompt}: ")
412
412
  if noecho
413
- IO.console.noecho {|io| io.gets.chomp }
414
- puts
413
+ IO.console.noecho {|io| io.gets.chomp }.tap { puts }
415
414
  else
416
415
  IO.console.gets.chomp
417
416
  end
@@ -1763,8 +1763,8 @@ module HexaPDF
1763
1763
  if obj[:Subtype] != :Image
1764
1764
  width /= obj.box.width.to_f
1765
1765
  height /= obj.box.height.to_f
1766
- left -= obj.box.left
1767
- bottom -= obj.box.bottom
1766
+ left -= obj.box.left * width
1767
+ bottom -= obj.box.bottom * height
1768
1768
  end
1769
1769
 
1770
1770
  if left == 0 && bottom == 0 && width == 1 && height == 1
@@ -34,8 +34,10 @@
34
34
  # commercial licenses are available at <https://gettalong.at/hexapdf/>.
35
35
  #++
36
36
 
37
+ require 'net/http'
37
38
  require 'openssl'
38
39
  require 'stringio'
40
+ require 'uri'
39
41
  require 'hexapdf/digital_signature'
40
42
  require 'hexapdf/error'
41
43
 
@@ -175,6 +177,41 @@ module HexaPDF
175
177
  io.close if io && io != file_or_io
176
178
  end
177
179
 
180
+ # Adds long-term validation information to the document.
181
+ #
182
+ # For each certificate OCSP is tried as it is smaller. If OCSP is not available, the CRL is
183
+ # used. If a problem is encountered, an error is thrown.
184
+ def add_ltv_information
185
+ dss = @document.catalog.dss
186
+
187
+ each do |signature|
188
+ certificates = signature.signature_handler.certificate_chain.dup
189
+ if (tsa_token = signature.signature_handler.embedded_tsa_signature)
190
+ certificates.concat(tsa_token.certificates)
191
+ end
192
+ cert_from_subject = certificates.each_with_object({}) {|c, h| h[c.subject] = c }
193
+
194
+ certs = []
195
+ ocsps = []
196
+ crls = []
197
+ certificates.each do |cert|
198
+ certs << cert.to_der
199
+ next if cert.issuer == cert.subject # skip self-signed root CA
200
+
201
+ issuer = cert_from_subject[cert.issuer]
202
+ if issuer && (ocsp_der = fetch_ocsp_response(cert, issuer))
203
+ ocsps << ocsp_der
204
+ elsif (crl_der = fetch_crl(cert))
205
+ crls << crl_der
206
+ else
207
+ raise HexaPDF::Error, "No OCSP and CRL response could be fetched for #{cert.subject}"
208
+ end
209
+ end
210
+
211
+ dss.add_vri(signature, certs: certs, ocsps: ocsps, crls: crls)
212
+ end
213
+ end
214
+
178
215
  # :call-seq:
179
216
  # signatures.each {|signature| block } -> signatures
180
217
  # signatures.each -> Enumerator
@@ -205,6 +242,74 @@ module HexaPDF
205
242
  "Signature#{index}"
206
243
  end
207
244
 
245
+ # Fetches an OCSP response for +cert+ issued by +issuer+. Returns the DER-encoded response, or
246
+ # +nil+ if no URL is found or the request fails.
247
+ def fetch_ocsp_response(cert, issuer)
248
+ url = cert.ocsp_uris&.first
249
+ return nil unless url
250
+
251
+ certificate_id = OpenSSL::OCSP::CertificateId.new(cert, issuer)
252
+ req = OpenSSL::OCSP::Request.new
253
+ req.add_certid(certificate_id)
254
+ req.add_nonce
255
+
256
+ url = URI(url)
257
+ http_request = Net::HTTP::Post.new(url, 'Content-Type' => 'application/ocsp-request')
258
+ http_request.body = req.to_der
259
+ http_response = Net::HTTP.start(url.hostname, url.port, use_ssl: (url.scheme == 'https')) do |http|
260
+ http.request(http_request)
261
+ end
262
+
263
+ if http_response.kind_of?(Net::HTTPOK)
264
+ ocsp_der = http_response.body
265
+ response = OpenSSL::OCSP::Response.new(ocsp_der)
266
+ basic_response = response.basic
267
+ if response.status != OpenSSL::OCSP::RESPONSE_STATUS_SUCCESSFUL ||
268
+ req.check_nonce(basic_response) == 0
269
+ return nil
270
+ end
271
+ single_response = basic_response.find_response(certificate_id)
272
+ return nil unless single_response && single_response.check_validity
273
+ if single_response.cert_status != OpenSSL::OCSP::V_CERTSTATUS_GOOD
274
+ raise HexaPDF::Error, "OCSP response indicates that the certificate is not valid"
275
+ end
276
+ ocsp_der
277
+ else
278
+ nil
279
+ end
280
+ rescue HexaPDF::Error
281
+ raise
282
+ rescue
283
+ nil
284
+ end
285
+
286
+ # Fetches a CRL for +cert+. Returns the DER-encoded CRL, or +nil+ if no URL is found or the
287
+ # request fails.
288
+ def fetch_crl(cert)
289
+ url = cert.crl_uris&.first
290
+ return nil unless url
291
+
292
+ http_response = Net::HTTP.get_response(URI(url))
293
+ if http_response.kind_of?(Net::HTTPOK)
294
+ crl_der = http_response.body
295
+ cert_serial = cert.serial
296
+ crl = OpenSSL::X509::CRL.new(crl_der)
297
+ if crl_der.include?(OpenSSL::ASN1::Integer.new(cert_serial).to_der)
298
+ crl = OpenSSL::X509::CRL.new(crl_der)
299
+ if crl.revoked.find {|r| r.serial == cert_serial }
300
+ raise HexaPDF::Error, "CRL response indicates that the certificate is revoked"
301
+ end
302
+ end
303
+ crl_der
304
+ else
305
+ nil
306
+ end
307
+ rescue HexaPDF::Error
308
+ raise
309
+ rescue
310
+ nil
311
+ end
312
+
208
313
  end
209
314
 
210
315
  end
@@ -53,8 +53,6 @@ module HexaPDF
53
53
  # As the created CMS object is only meant to be used in the context of PDF signing, it also
54
54
  # restricts certain things, like allowing only a single signer.
55
55
  #
56
- # Additionally, only RSA signatures are currently supported!
57
- #
58
56
  # See: PDF2.0 s12.8.3.3, PDF2.0 s12.8.3.4, RFC5652, ETSI TS 102 778 Parts 1-4
59
57
  class SignedDataCreator
60
58
 
@@ -693,8 +693,8 @@ module HexaPDF
693
693
  # If not changed, the default signing handler is DigitalSignature::Signing::DefaultHandler.
694
694
  #
695
695
  # *Note*: Once signing is done the document cannot be changed anymore since it was written
696
- # during the signing process. If a document needs to be signed multiple times, it needs to be
697
- # loaded again afterwards.
696
+ # during the signing process. If a document needs to be signed multiple times or if LTV
697
+ # information needs to be added, it needs to be loaded again afterwards.
698
698
  def sign(file_or_io, handler: :default, signature: nil, write_options: {}, **handler_options)
699
699
  handler = signatures.signing_handler(name: handler, **handler_options)
700
700
  signatures.add(file_or_io, handler, signature: signature, write_options: write_options)
@@ -541,8 +541,8 @@ module HexaPDF
541
541
 
542
542
  page = canvas.context
543
543
  matrix = canvas.graphics_state.ctm
544
- quad_points = [*matrix.evaluate(0, 0), *matrix.evaluate(box.width, 0),
545
- *matrix.evaluate(box.width, box.height), *matrix.evaluate(0, box.height)]
544
+ quad_points = [*matrix.evaluate(0, box.height), *matrix.evaluate(box.width, box.height),
545
+ *matrix.evaluate(0, 0), *matrix.evaluate(box.width, 0)]
546
546
  x_minmax = quad_points.values_at(0, 2, 4, 6).minmax
547
547
  y_minmax = quad_points.values_at(1, 3, 5, 7).minmax
548
548
  border_color = case @border_color
@@ -43,62 +43,6 @@ HARFBUZZ_AVAILABLE = begin
43
43
  rescue LoadError
44
44
  end
45
45
 
46
- if HARFBUZZ_AVAILABLE
47
- class HarfBuzz::Buffer #:nodoc:
48
-
49
- GLYPH_INFO_SIZE = HarfBuzz::C::HbGlyphInfoT.size
50
- GLYPH_INFO_CODEPOINT_OFFSET = HarfBuzz::C::HbGlyphInfoT.offset_of(:codepoint)
51
- GLYPH_INFO_CLUSTER_OFFSET = HarfBuzz::C::HbGlyphInfoT.offset_of(:cluster)
52
- GLYPH_POS_SIZE = HarfBuzz::C::HbGlyphPositionT.size
53
- GLYPH_POS_XADVANCE_OFFSET = HarfBuzz::C::HbGlyphPositionT.offset_of(:x_advance)
54
- GLYPH_POS_YADVANCE_OFFSET = HarfBuzz::C::HbGlyphPositionT.offset_of(:y_advance)
55
- GLYPH_POS_XOFFSET_OFFSET = HarfBuzz::C::HbGlyphPositionT.offset_of(:x_offset)
56
- GLYPH_POS_YOFFSET_OFFSET = HarfBuzz::C::HbGlyphPositionT.offset_of(:y_offset)
57
-
58
- # Iterates efficiently over the shaping result without creating intermediary objects.
59
- def each_result
60
- return enum_for(__method__) unless block_given?
61
-
62
- length_ptr = FFI::MemoryPointer.new(:uint)
63
- infos_ptr = HarfBuzz::C.hb_buffer_get_glyph_infos(@ptr, length_ptr)
64
- length_ptr = FFI::MemoryPointer.new(:uint)
65
- positions_ptr = HarfBuzz::C.hb_buffer_get_glyph_positions(@ptr, length_ptr)
66
- length = length_ptr.read_uint
67
-
68
- return if infos_ptr.null? || positions_ptr.null? || length.zero?
69
-
70
- last_info_cluster_offset = (length - 1) * GLYPH_INFO_SIZE + GLYPH_INFO_CLUSTER_OFFSET
71
- i = 0
72
- while i < length
73
- info_offset = i * GLYPH_INFO_SIZE
74
- pos_offset = i * GLYPH_POS_SIZE
75
-
76
- glyph_id = infos_ptr.get_uint32(info_offset + GLYPH_INFO_CODEPOINT_OFFSET)
77
- cluster = infos_ptr.get_uint32(info_offset + GLYPH_INFO_CLUSTER_OFFSET)
78
-
79
- next_cluster = nil
80
- tmp_offset = info_offset + GLYPH_INFO_CLUSTER_OFFSET + GLYPH_INFO_SIZE
81
- while tmp_offset <= last_info_cluster_offset &&
82
- (next_cluster = infos_ptr.get_uint32(tmp_offset)) == cluster
83
- tmp_offset += GLYPH_INFO_SIZE
84
- next_cluster = nil
85
- end
86
-
87
- x_advance = positions_ptr.get_int32(pos_offset + GLYPH_POS_XADVANCE_OFFSET)
88
- y_advance = positions_ptr.get_int32(pos_offset + GLYPH_POS_YADVANCE_OFFSET)
89
- x_offset = positions_ptr.get_int32(pos_offset + GLYPH_POS_XOFFSET_OFFSET)
90
- y_offset = positions_ptr.get_int32(pos_offset + GLYPH_POS_YOFFSET_OFFSET)
91
-
92
- yield(glyph_id, cluster, next_cluster, x_advance, y_advance, x_offset, y_offset)
93
-
94
- i += 1
95
- end
96
-
97
- self
98
- end
99
- end
100
- end
101
-
102
46
  module HexaPDF
103
47
  module Layout
104
48
 
@@ -188,7 +132,7 @@ module HexaPDF
188
132
  items = text_fragment.items.clear
189
133
  last_cluster = nil
190
134
  last_y_offset = 0
191
- buffer.each_result do |glyph_id, cluster, next_cluster, x_advance, y_advance, x_offset, y_offset|
135
+ buffer.each_glyph_with_clusters do |glyph_id, cluster, next_cluster, x_advance, y_advance, x_offset, y_offset|
192
136
  advance = (x_advance - x_offset) * font.scaling_factor
193
137
 
194
138
  # 1. Determine the source characters for each glyph via their cluster numbers. If two or
@@ -51,6 +51,9 @@ module HexaPDF
51
51
  # Note that the /Order, /AS and /Locked fields of the default optional content configuration
52
52
  # dictionary are not preserved.
53
53
  #
54
+ # The imported page can optionally be resized to a given page size. Note, however, that this
55
+ # will make all interactive elements (e.g. annotation widgets and form fields) static.
56
+ #
54
57
  # Example:
55
58
  #
56
59
  # doc.task(:import_pages, source: source_doc, pages: [1..-2])
@@ -87,7 +90,20 @@ module HexaPDF
87
90
  #
88
91
  # +:merge+:: Merge AcroForm fields using the MergeAcroForm task.
89
92
  # +:ignore+:: Ignore AcroForm fields.
90
- def self.call(doc, source:, pages: :all, append: true, ocgs: :preserve, acro_form: :merge)
93
+ #
94
+ # +:resize_to+::
95
+ # Resizes the pages to the given page size (either a pre-defined name or an array
96
+ # specifying the media box), potentially deforming it. This is done by converting the page
97
+ # to a Form XObject and then painting it.
98
+ #
99
+ # Note 1: By specifying this argument, the argument +acro_form+ is handled as if it were
100
+ # :ignore.
101
+ #
102
+ # Note 2: Annotations without an appearance stream are ignored. If that is something that
103
+ # needs to be considered, +page.flatten_annotations+ should be called beforehand for each
104
+ # source page and the results handled.
105
+ def self.call(doc, source:, pages: :all, append: true, ocgs: :preserve, acro_form: :merge,
106
+ resize_to: nil)
91
107
  # Retrieve all specified source pages
92
108
  pages = if pages == :all
93
109
  source.pages.each.to_a
@@ -106,6 +122,7 @@ module HexaPDF
106
122
  # Import the source pages and optionally append them to the target page tree
107
123
  pages = pages.map do |page|
108
124
  imported_page = doc.import(page)
125
+ imported_page = resize_page(doc, imported_page, resize_to) if resize_to
109
126
  doc.pages << imported_page if append
110
127
  imported_page
111
128
  end
@@ -128,17 +145,19 @@ module HexaPDF
128
145
  ocgs.merge(obj[:OCGs].to_ary)
129
146
  end
130
147
  end
148
+ process_page_or_form = lambda do |page_or_form|
149
+ page_or_form.resources[:Properties]&.each do |name, obj|
150
+ process_ocg_or_ocmd.call(obj) if obj
151
+ end
152
+ page_or_form.resources[:XObject]&.each do |name, obj|
153
+ process_ocg_or_ocmd.call(obj[:OC]) if obj.key?(:OC)
154
+ process_page_or_form.call(obj) if obj[:Subtype] == :Form
155
+ end
156
+ end
131
157
  seen_resources = {}
132
158
  pages.each do |page|
133
159
  unless seen_resources[page.resources] # handle case when pages share the resources dict
134
- page.resources[:Properties]&.each do |name, obj|
135
- next unless obj
136
- process_ocg_or_ocmd.call(obj)
137
- end
138
-
139
- page.resources[:XObject]&.each do |name, obj|
140
- process_ocg_or_ocmd.call(obj[:OC]) if obj.key?(:OC)
141
- end
160
+ process_page_or_form.call(page)
142
161
  end
143
162
 
144
163
  page.each_annotation do |annot|
@@ -178,6 +197,19 @@ module HexaPDF
178
197
  (target_config[:RBGroups] ||= []) << result
179
198
  end
180
199
  end
200
+ private_class_method :preserve_ocgs
201
+
202
+ # Resizes the +page+ to the given +media_box+ exactly, potentially deforming it.
203
+ def self.resize_page(doc, page, media_box)
204
+ page.flatten_annotations
205
+ form = page.to_form_xobject
206
+ doc.delete(page)
207
+ page = doc.pages.create(media_box: media_box)
208
+ media_box = page.box(:media) # needed because before media_box could be e.g. :A4
209
+ page.canvas.xobject(form, at: [0, 0], width: media_box.width, height: media_box.height)
210
+ page
211
+ end
212
+ private_class_method :resize_page
181
213
 
182
214
  end
183
215
 
@@ -505,7 +505,7 @@ module HexaPDF
505
505
  top_index = @field.list_box_top_index
506
506
  items = @document.layout.text_fragments(option_items[top_index..-1].join("\n"), style: style)
507
507
  # Should use /I but if it differs from /V, we need to use /V; so just use /V...
508
- indices = [@field.field_value].flatten.compact.map {|val| option_items.index(val) }
508
+ indices = [@field.field_value].flatten.compact.filter_map {|val| option_items.index(val) }
509
509
 
510
510
  layouter = Layout::TextLayouter.new(style)
511
511
  layouter.style.text_align(@field.text_alignment).line_spacing(:proportional, 1.25)
@@ -199,7 +199,9 @@ module HexaPDF
199
199
 
200
200
  # Returns the index of the first visible option item of a list box.
201
201
  def list_box_top_index
202
- self[:TI]
202
+ prepare_option_items
203
+ ti = self[:TI]
204
+ ti.kind_of?(Integer) && self[:Opt] && ti.between?(0, self[:Opt].length - 1) ? ti : 0
203
205
  end
204
206
 
205
207
  # Makes the option item referred to via the given +index+ the first visible option item of a
@@ -260,7 +260,7 @@ module HexaPDF
260
260
 
261
261
  # Returns +true+ if the field contains an embedded widget.
262
262
  def embedded_widget?
263
- key?(:Subtype)
263
+ self[:Subtype] == :Widget
264
264
  end
265
265
 
266
266
  # :call-seq:
@@ -94,6 +94,11 @@ module HexaPDF
94
94
  (self[:VRI] ||= {})[key] = document.add(vri)
95
95
  end
96
96
 
97
+ # Returns the VRI dictionary for the given signature if it exists or else +nil+.
98
+ def vri_for(signature)
99
+ self[:VRI] && self[:VRI][OpenSSL::Digest::SHA1.hexdigest(signature.contents).upcase.to_sym]
100
+ end
101
+
97
102
  # Adds the DER-encoded certificate to the /Certs array if not already present and returns
98
103
  # the stream object containing it.
99
104
  def add_cert(cert_der)
@@ -112,6 +117,15 @@ module HexaPDF
112
117
  add_data_as_stream_to_field(crl_der, :CRLs)
113
118
  end
114
119
 
120
+ # Returns all certificates in the /Certs array as OpenSSL::X509::Certificate objects.
121
+ def certificates
122
+ if key?(:Certs)
123
+ self[:Certs].length.times.map {|i| OpenSSL::X509::Certificate.new(self[:Certs][i].stream) }
124
+ else
125
+ []
126
+ end
127
+ end
128
+
115
129
  private
116
130
 
117
131
  # Adds the given string +data+ to the +field+ array as stream object and returns the resulting
@@ -37,6 +37,6 @@
37
37
  module HexaPDF
38
38
 
39
39
  # The version of HexaPDF.
40
- VERSION = '1.10.0'
40
+ VERSION = '1.11.0'
41
41
 
42
42
  end
@@ -942,7 +942,7 @@ describe HexaPDF::Content::Canvas do
942
942
  it "correctly serializes the form with just the width given" do
943
943
  @canvas.image(@form, at: [1, 2], width: 50)
944
944
  assert_operators(@page.contents, [[:save_graphics_state],
945
- [:concatenate_matrix, [0.5, 0, 0, 0.5, -99, -48]],
945
+ [:concatenate_matrix, [0.5, 0, 0, 0.5, -49, -23]],
946
946
  [:paint_xobject, [:XO1]],
947
947
  [:restore_graphics_state]])
948
948
  end
@@ -950,7 +950,7 @@ describe HexaPDF::Content::Canvas do
950
950
  it "correctly serializes the form with just the height given" do
951
951
  @canvas.image(@form, at: [1, 2], height: 10)
952
952
  assert_operators(@page.contents, [[:save_graphics_state],
953
- [:concatenate_matrix, [0.2, 0, 0, 0.2, -99, -48]],
953
+ [:concatenate_matrix, [0.2, 0, 0, 0.2, -19, -8]],
954
954
  [:paint_xobject, [:XO1]],
955
955
  [:restore_graphics_state]])
956
956
  end
@@ -958,7 +958,7 @@ describe HexaPDF::Content::Canvas do
958
958
  it "correctly serializes the form with both width and height given" do
959
959
  @canvas.image(@form, at: [1, 2], width: 50, height: 10)
960
960
  assert_operators(@page.contents, [[:save_graphics_state],
961
- [:concatenate_matrix, [0.5, 0, 0, 0.2, -99, -48]],
961
+ [:concatenate_matrix, [0.5, 0, 0, 0.2, -49, -8]],
962
962
  [:paint_xobject, [:XO1]],
963
963
  [:restore_graphics_state]])
964
964
  end