hexapdf 1.9.1 → 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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/lib/hexapdf/cli/command.rb +1 -2
  4. data/lib/hexapdf/configuration.rb +2 -2
  5. data/lib/hexapdf/content/canvas.rb +2 -2
  6. data/lib/hexapdf/content/smart_text_extractor.rb +3 -2
  7. data/lib/hexapdf/dictionary_fields.rb +7 -0
  8. data/lib/hexapdf/digital_signature/cms_handler.rb +27 -2
  9. data/lib/hexapdf/digital_signature/pkcs1_handler.rb +5 -0
  10. data/lib/hexapdf/digital_signature/signatures.rb +105 -0
  11. data/lib/hexapdf/digital_signature/signing/signed_data_creator.rb +0 -2
  12. data/lib/hexapdf/document.rb +2 -2
  13. data/lib/hexapdf/encryption/security_handler.rb +3 -1
  14. data/lib/hexapdf/layout/style.rb +2 -2
  15. data/lib/hexapdf/layout/text_fragment.rb +5 -1
  16. data/lib/hexapdf/layout/text_shaper.rb +1 -57
  17. data/lib/hexapdf/parser.rb +1 -2
  18. data/lib/hexapdf/serializer.rb +1 -1
  19. data/lib/hexapdf/task/import_pages.rb +41 -9
  20. data/lib/hexapdf/type/acro_form/appearance_generator.rb +1 -1
  21. data/lib/hexapdf/type/acro_form/choice_field.rb +3 -1
  22. data/lib/hexapdf/type/acro_form/field.rb +1 -1
  23. data/lib/hexapdf/type/catalog.rb +8 -1
  24. data/lib/hexapdf/type/document_security_store.rb +81 -13
  25. data/lib/hexapdf/type/font_type0.rb +1 -1
  26. data/lib/hexapdf/version.rb +1 -1
  27. data/test/hexapdf/content/test_canvas.rb +3 -3
  28. data/test/hexapdf/content/test_smart_text_extractor.rb +7 -0
  29. data/test/hexapdf/digital_signature/common.rb +209 -2
  30. data/test/hexapdf/digital_signature/test_cms_handler.rb +4 -2
  31. data/test/hexapdf/digital_signature/test_pkcs1_handler.rb +4 -0
  32. data/test/hexapdf/digital_signature/test_signatures.rb +131 -0
  33. data/test/hexapdf/encryption/test_security_handler.rb +12 -0
  34. data/test/hexapdf/layout/test_style.rb +3 -1
  35. data/test/hexapdf/layout/test_text_fragment.rb +11 -4
  36. data/test/hexapdf/task/test_import_pages.rb +42 -1
  37. data/test/hexapdf/test_dictionary_fields.rb +8 -0
  38. data/test/hexapdf/test_parser.rb +25 -9
  39. data/test/hexapdf/test_serializer.rb +7 -1
  40. data/test/hexapdf/type/acro_form/test_appearance_generator.rb +1 -1
  41. data/test/hexapdf/type/acro_form/test_choice_field.rb +5 -0
  42. data/test/hexapdf/type/acro_form/test_field.rb +3 -1
  43. data/test/hexapdf/type/test_catalog.rb +10 -0
  44. data/test/hexapdf/type/test_document_security_store.rb +120 -0
  45. data/test/test_helper.rb +7 -3
  46. metadata +939 -904
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e272dafd41bb732e5110aadc794399a3e5d9823571de2ee67f119f05422af9ac
4
- data.tar.gz: 1269ed888e0b5c3fac4b4a25afbcdab75835252aadaf46fe6a92cfbde379f330
3
+ metadata.gz: da17a2a9dd8459e6f4624f59621c84f1b0be73f4d23347bffcdd6c534b58680e
4
+ data.tar.gz: ecdcfedc4f89722f799d04fda467acbed24bf256a1fa626623fce1d6399d6d98
5
5
  SHA512:
6
- metadata.gz: 69f36a31e49cb7b434bc53e63c2fde4c271a506046224a0dbb76b473176f435d618d0e9de90a64f3b95c4fa64cec8a8addbcb4c5c997ac9790fd79bd46093306
7
- data.tar.gz: 786835f800b29263364d97c872465b7fcaf431bf846a793a3fe502810090bc6ce1b94089a18f2787ed5cc3e9b982cf0a2d33db68b7812861f938e4f209af17d4
6
+ metadata.gz: 5a39a7635005c7ef8be1dc0a1efc21b1aca00fba3bedb80886e5d909d9fc0ebfed0f967fff6f10f96ed6b05a883fee0337fea3802bfcde3a93a644e512d0ee3b
7
+ data.tar.gz: 4e9ccdbe854d0e5fb00e7042d61c758da93c5b354982b48a20f4a6cec3a07d209dac5711d1eb9b3b6274d8662acac8851124a838bed5795cc6e40c924f4bba26
data/CHANGELOG.md CHANGED
@@ -1,3 +1,58 @@
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
+
31
+ ## 1.10.0 - 2026-08-21
32
+
33
+ ### Added
34
+
35
+ * Support for PDF 2.0 UTF-8 strings to
36
+ [HexaPDF::DictionaryFields::StringConverter]
37
+ * [HexaPDF::Type::Catalog#dss] for returning the document security store
38
+ * [HexaPDF::DigitalSignature::PKCS1Handler#embedded_tsa_signature]
39
+ * [HexaPDF::Type::DocumentSecurityStore] convenience methods for adding entries
40
+
41
+ ### Fixed
42
+
43
+ * Removed invalid dictionary fields from [HexaPDF::Type::DocumentSecurityStore]
44
+ * [HexaPDF::DigitalSignature::CMSHandler] to handle padding of /Contents
45
+ correctly
46
+ * Regression in [HexaPDF::Layout::TextFragment::create_with_fallback_glyphs]
47
+ with respect to handling `\r\n` as a single newline
48
+ * Double decryption when reconstructing invalid files
49
+ * Parsing encrypted indirect objects that should be streams but aren't
50
+ * Serializing of large floats that used scientific instead of fixed-point
51
+ notation
52
+ * [HexaPDF::Content::SmartTextExtractor::layout_text_runs] to work in case of
53
+ degenerate input where the majority of text runs have zero height
54
+
55
+
1
56
  ## 1.9.1 - 2026-06-09
2
57
 
3
58
  ### Fixed
@@ -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
@@ -254,8 +254,8 @@ module HexaPDF
254
254
  # In nearly all cases this option should not be changed from its default setting!
255
255
  #
256
256
  # document.on_invalid_string::
257
- # A callable object that takes the invalid UTF-16BE encoded string and returns a valid UTF-8
258
- # encoded string.
257
+ # A callable object that takes the invalid UTF-16BE or UTF-8 encoded string and returns a valid
258
+ # UTF-8 encoded string.
259
259
  #
260
260
  # The default is to remove all invalid characters.
261
261
  #
@@ -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
@@ -172,8 +172,9 @@ module HexaPDF
172
172
  return '' if text_runs.empty?
173
173
 
174
174
  # Use the median height of all text runs as an approximation of the main font size used on
175
- # the page. The line tolerance uses a hard floor for small fonts.
176
- median_height = median(text_runs.map(&:height).sort)
175
+ # the page. In case the majority of text runs have a height of 0, use a non-zero height
176
+ # value. The line tolerance uses a hard floor for small fonts.
177
+ median_height = [median(text_runs.map(&:height).sort), 1].max
177
178
  line_tolerance = [median_height * line_tolerance_factor, 2].max
178
179
 
179
180
  # Group the text runs into lines which are sorted top to bottom. Text runs are pre-sorted by
@@ -262,6 +262,13 @@ module HexaPDF
262
262
  else
263
263
  document.config['document.on_invalid_string'].call(str)
264
264
  end
265
+ elsif str.getbyte(0) == 239 && str.getbyte(1) == 187 && str.getbyte(2) == 191
266
+ str = str[3..-1].force_encoding(Encoding::UTF_8)
267
+ if str.valid_encoding?
268
+ str
269
+ else
270
+ document.config['document.on_invalid_string'].call(str)
271
+ end
265
272
  else
266
273
  Utils::PDFDocEncoding.convert_to_utf8(str)
267
274
  end
@@ -92,7 +92,7 @@ module HexaPDF
92
92
  return @embedded_tsa_signature if defined?(@embedded_tsa_signature)
93
93
 
94
94
  @embedded_tsa_signature = nil
95
- p7 = OpenSSL::ASN1.decode(signature_dict.contents.sub(/\x00*\z/, ''))
95
+ p7 = decode_asn1(signature_dict.contents)
96
96
  signed_data = p7.value[1].value[0]
97
97
  signer_info = signed_data.value[-1].value[0] # first (and only) signer info
98
98
  return unless signer_info.value[-1].tag == 1 # check for unsigned attributes
@@ -140,7 +140,7 @@ module HexaPDF
140
140
 
141
141
  if signature_dict.signature_type == 'ETSI.RFC3161'
142
142
  # Getting the needed values is not directly supported by Ruby OpenSSL
143
- p7 = OpenSSL::ASN1.decode(signature_dict.contents.sub(/\x00*\z/, ''))
143
+ p7 = decode_asn1(signature_dict.contents)
144
144
  signed_data = p7.value[1].value[0]
145
145
  content_info = signed_data.value[2]
146
146
  content = OpenSSL::ASN1.decode(content_info.value[1].value[0].value)
@@ -175,6 +175,31 @@ module HexaPDF
175
175
  result
176
176
  end
177
177
 
178
+ private
179
+
180
+ # Decode the first data structure in the given +data+ binary string.
181
+ #
182
+ # Since ASN1.decode raises an error if there are trailing bytes in +data+, we need to try
183
+ # several things to get the first data structure out of +data+ that is possibly zero-padded
184
+ # (due to definite and indefinite encodings; \x00\x00 is the EOD marker for indefinite
185
+ # encodings complicating things).
186
+ def decode_asn1(data)
187
+ length = OpenSSL::ASN1.traverse(data) do |_depth, _offset, header_length, length, *|
188
+ break length > 0 ? header_length + length : 0
189
+ end
190
+ if length > 0
191
+ OpenSSL::ASN1.decode(data[0, length])
192
+ else
193
+ begin
194
+ OpenSSL::ASN1.decode(data)
195
+ rescue OpenSSL::ASN1::ASN1Error => e
196
+ length = e.message.scan(/Total bytes read: (\d+)/)&.first&.first.to_i
197
+ data = data[0, length]
198
+ retry
199
+ end
200
+ end
201
+ end
202
+
178
203
  end
179
204
 
180
205
  end
@@ -59,6 +59,11 @@ module HexaPDF
59
59
  certificate_chain.first
60
60
  end
61
61
 
62
+ # Returns +nil+.
63
+ def embedded_tsa_signature
64
+ nil
65
+ end
66
+
62
67
  # Verifies the signature using the provided OpenSSL::X509::Store object.
63
68
  def verify(store, allow_self_signed: false)
64
69
  result = super
@@ -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)
@@ -276,7 +276,9 @@ module HexaPDF
276
276
  str.replace(string_algorithm.decrypt(key, str, &error_proc))
277
277
  end
278
278
 
279
- if obj.kind_of?(HexaPDF::Stream) && obj.raw_stream.filter[0] != :Crypt
279
+ # The (obj.raw_stream == '') case may occur for PDFs where a typed object that should be a
280
+ # stream isn't one. For example, if a /Type /Form object doesn't have stream ... endstream.
281
+ if obj.kind_of?(HexaPDF::Stream) && obj.raw_stream != '' && obj.raw_stream.filter[0] != :Crypt
280
282
  unless string_algorithm == stream_algorithm
281
283
  key = object_key(obj.oid, obj.gen, stream_algorithm)
282
284
  end
@@ -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
@@ -119,7 +119,11 @@ module HexaPDF
119
119
  items = []
120
120
  end
121
121
  if glyph.control_char?
122
- result.append(new([glyph], style))
122
+ if result.last&.style == style
123
+ result.last.items << glyph
124
+ else
125
+ result.append(new([glyph], style))
126
+ end
123
127
  else
124
128
  fallback = yield(codepoint, glyph)
125
129
  unless fallback.empty?
@@ -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
@@ -531,8 +531,7 @@ module HexaPDF
531
531
 
532
532
  loader = lambda do |xref_entry|
533
533
  obj, oid, gen, stream = parse_indirect_object(xref_entry.pos)
534
- obj = @document.wrap(obj, oid: oid, gen: gen, stream: stream)
535
- @document.security_handler ? @document.security_handler.decrypt(obj) : obj
534
+ @document.wrap(obj, oid: oid, gen: gen, stream: stream)
536
535
  end
537
536
 
538
537
  @in_reconstruct_revision = false
@@ -206,7 +206,7 @@ module HexaPDF
206
206
  if -0.0001 < obj && obj < 0.0001 && obj != 0
207
207
  sprintf("%.6f", obj)
208
208
  elsif obj.finite?
209
- obj.round(6).to_s
209
+ obj.clamp(-999999999999999, 999999999999999).round(6).to_s
210
210
  else
211
211
  raise HexaPDF::Error, "Can't serialize special floating point number #{obj}"
212
212
  end
@@ -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:
@@ -84,7 +84,7 @@ module HexaPDF
84
84
  define_field :Requirements, type: PDFArray, version: '1.7'
85
85
  define_field :Collection, type: Dictionary, version: '1.7'
86
86
  define_field :NeedsRendering, type: Boolean, version: '1.7'
87
- define_field :DSS, type: Dictionary, version: '2.0'
87
+ define_field :DSS, type: :DSS, version: '2.0'
88
88
  define_field :AF, type: PDFArray, version: '2.0'
89
89
  define_field :DPartRoot, type: Dictionary, version: '2.0'
90
90
 
@@ -124,6 +124,13 @@ module HexaPDF
124
124
  self[:OCProperties] ||= document.add({OCGs: [], D: {Creator: 'HexaPDF'}}, type: :XXOCProperties)
125
125
  end
126
126
 
127
+ # Returns the document security store, creating it if needed.
128
+ #
129
+ # See: DocumentSecurityStore
130
+ def dss
131
+ self[:DSS] ||= document.add({}, type: :DSS)
132
+ end
133
+
127
134
  # Returns the main AcroForm object.
128
135
  #
129
136
  # * If an AcroForm object exists, the +create+ argument is not used.