hexapdf 0.40.0 → 0.41.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/examples/019-acro_form.rb +12 -23
  4. data/examples/027-composer_optional_content.rb +1 -1
  5. data/examples/030-pdfa.rb +6 -6
  6. data/examples/031-acro_form_java_script.rb +101 -0
  7. data/lib/hexapdf/cli/command.rb +11 -0
  8. data/lib/hexapdf/cli/form.rb +38 -9
  9. data/lib/hexapdf/cli/info.rb +4 -0
  10. data/lib/hexapdf/configuration.rb +10 -0
  11. data/lib/hexapdf/content/canvas.rb +2 -0
  12. data/lib/hexapdf/document/layout.rb +8 -1
  13. data/lib/hexapdf/encryption/aes.rb +13 -6
  14. data/lib/hexapdf/encryption/security_handler.rb +6 -4
  15. data/lib/hexapdf/font/cmap/parser.rb +1 -5
  16. data/lib/hexapdf/font/cmap.rb +22 -3
  17. data/lib/hexapdf/font_loader/from_configuration.rb +1 -1
  18. data/lib/hexapdf/font_loader/variant_from_name.rb +72 -0
  19. data/lib/hexapdf/font_loader.rb +1 -0
  20. data/lib/hexapdf/layout/style.rb +5 -4
  21. data/lib/hexapdf/type/acro_form/appearance_generator.rb +11 -76
  22. data/lib/hexapdf/type/acro_form/field.rb +14 -0
  23. data/lib/hexapdf/type/acro_form/form.rb +25 -8
  24. data/lib/hexapdf/type/acro_form/java_script_actions.rb +498 -0
  25. data/lib/hexapdf/type/acro_form/text_field.rb +78 -0
  26. data/lib/hexapdf/type/acro_form.rb +1 -0
  27. data/lib/hexapdf/type/annotations/widget.rb +1 -1
  28. data/lib/hexapdf/version.rb +1 -1
  29. data/test/hexapdf/encryption/test_aes.rb +18 -8
  30. data/test/hexapdf/encryption/test_security_handler.rb +17 -0
  31. data/test/hexapdf/encryption/test_standard_security_handler.rb +1 -1
  32. data/test/hexapdf/font/cmap/test_parser.rb +5 -3
  33. data/test/hexapdf/font/test_cmap.rb +8 -0
  34. data/test/hexapdf/font_loader/test_from_configuration.rb +4 -0
  35. data/test/hexapdf/font_loader/test_variant_from_name.rb +34 -0
  36. data/test/hexapdf/type/acro_form/test_appearance_generator.rb +31 -47
  37. data/test/hexapdf/type/acro_form/test_field.rb +11 -0
  38. data/test/hexapdf/type/acro_form/test_form.rb +33 -0
  39. data/test/hexapdf/type/acro_form/test_java_script_actions.rb +226 -0
  40. data/test/hexapdf/type/acro_form/test_text_field.rb +44 -0
  41. metadata +7 -2
@@ -100,8 +100,10 @@ module HexaPDF
100
100
  # The writing mode of the CMap: 0 for horizontal, 1 for vertical writing.
101
101
  attr_accessor :wmode
102
102
 
103
- attr_reader :codespace_ranges, :cid_mapping, :cid_range_mappings, :unicode_mapping # :nodoc:
104
- protected :codespace_ranges, :cid_mapping, :cid_range_mappings, :unicode_mapping
103
+ attr_reader :codespace_ranges, :cid_mapping, :cid_range_mappings, :unicode_mapping,
104
+ :unicode_range_mappings # :nodoc:
105
+ protected :codespace_ranges, :cid_mapping, :cid_range_mappings, :unicode_mapping,
106
+ :unicode_range_mappings
105
107
 
106
108
  # Creates a new CMap object.
107
109
  def initialize
@@ -109,6 +111,7 @@ module HexaPDF
109
111
  @cid_mapping = {}
110
112
  @cid_range_mappings = []
111
113
  @unicode_mapping = {}
114
+ @unicode_range_mappings = []
112
115
  end
113
116
 
114
117
  # Add all mappings from the given CMap to this CMap.
@@ -117,6 +120,7 @@ module HexaPDF
117
120
  @cid_mapping.merge!(cmap.cid_mapping)
118
121
  @cid_range_mappings.concat(cmap.cid_range_mappings)
119
122
  @unicode_mapping.merge!(cmap.unicode_mapping)
123
+ @unicode_range_mappings.concat(cmap.unicode_range_mappings)
120
124
  end
121
125
 
122
126
  # Add a codespace range using an array of ranges for the individual bytes.
@@ -193,10 +197,25 @@ module HexaPDF
193
197
  @unicode_mapping[code] = string
194
198
  end
195
199
 
200
+ # Adds a mapping from a range of character codes to strings starting with the given 16-bit
201
+ # integer values (representing the raw UTF-16BE characters).
202
+ def add_unicode_range_mapping(start_code, end_code, start_values)
203
+ @unicode_range_mappings << [start_code..end_code, start_values]
204
+ end
205
+
196
206
  # Returns the Unicode string in UTF-8 encoding for the given character code, or +nil+ if no
197
207
  # mapping was found.
198
208
  def to_unicode(code)
199
- unicode_mapping[code]
209
+ @unicode_mapping.fetch(code) do
210
+ @unicode_range_mappings.reverse_each do |range, start_values|
211
+ if range.cover?(code)
212
+ str = start_values[0..-2].append(start_values[-1] + code - range.first).
213
+ pack('n*').encode(::Encoding::UTF_8, ::Encoding::UTF_16BE)
214
+ return @unicode_mapping[code] = str
215
+ end
216
+ end
217
+ nil
218
+ end
200
219
  end
201
220
 
202
221
  end
@@ -62,7 +62,7 @@ module HexaPDF
62
62
  # Specifies whether the font should be subset if possible.
63
63
  #
64
64
  # This method uses the FromFile font loader behind the scenes.
65
- def self.call(document, name, variant: :none, subset: true)
65
+ def self.call(document, name, variant: :none, subset: true, **)
66
66
  file = document.config['font.map'].dig(name, variant)
67
67
  return nil if file.nil?
68
68
 
@@ -0,0 +1,72 @@
1
+ # -*- encoding: utf-8; frozen_string_literal: true -*-
2
+ #
3
+ #--
4
+ # This file is part of HexaPDF.
5
+ #
6
+ # HexaPDF - A Versatile PDF Creation and Manipulation Library For Ruby
7
+ # Copyright (C) 2014-2024 Thomas Leitner
8
+ #
9
+ # HexaPDF is free software: you can redistribute it and/or modify it
10
+ # under the terms of the GNU Affero General Public License version 3 as
11
+ # published by the Free Software Foundation with the addition of the
12
+ # following permission added to Section 15 as permitted in Section 7(a):
13
+ # FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY
14
+ # THOMAS LEITNER, THOMAS LEITNER DISCLAIMS THE WARRANTY OF NON
15
+ # INFRINGEMENT OF THIRD PARTY RIGHTS.
16
+ #
17
+ # HexaPDF is distributed in the hope that it will be useful, but WITHOUT
18
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
19
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
20
+ # License for more details.
21
+ #
22
+ # You should have received a copy of the GNU Affero General Public License
23
+ # along with HexaPDF. If not, see <http://www.gnu.org/licenses/>.
24
+ #
25
+ # The interactive user interfaces in modified source and object code
26
+ # versions of HexaPDF must display Appropriate Legal Notices, as required
27
+ # under Section 5 of the GNU Affero General Public License version 3.
28
+ #
29
+ # In accordance with Section 7(b) of the GNU Affero General Public
30
+ # License, a covered work must retain the producer line in every PDF that
31
+ # is created or manipulated using HexaPDF.
32
+ #
33
+ # If the GNU Affero General Public License doesn't fit your need,
34
+ # commercial licenses are available at <https://gettalong.at/hexapdf/>.
35
+ #++
36
+
37
+ require 'hexapdf/font/true_type_wrapper'
38
+ require 'hexapdf/font_loader/from_file'
39
+
40
+ module HexaPDF
41
+ module FontLoader
42
+
43
+ # This module translates font names like 'Helvetica bold' into the arguments 'Helvetica' and
44
+ # {variant: :bold}.
45
+ #
46
+ # This eases the usage of font names where specifying a font variant is not straight-forward.
47
+ # The actual loading of the font is deferred to Document::Fonts#add.
48
+ #
49
+ # Note that this should be the last entry in the list of font loaders to ensure correct
50
+ # operation.
51
+ module VariantFromName
52
+
53
+ # Returns a font wrapper for the given font by splitting the font name into the font name part
54
+ # and variant selector part. If the the resulting font cannot be resolved, +nil+ is returned.
55
+ #
56
+ # A font name should have the form 'Fontname selector' where selector can be 'bold', 'italic'
57
+ # or 'bold_italic', for example 'Helvetica bold'.
58
+ #
59
+ # Note that a supplied :variant keyword argument is ignored!
60
+ def self.call(document, name, recursive_invocation: false, **options)
61
+ return if recursive_invocation
62
+ name, variant = name.split(/ (?=(?:bold|italic|bold_italic)\z)/, 2)
63
+ return if variant.nil?
64
+
65
+ options[:variant] = variant.to_sym
66
+ document.fonts.add(name, **options, recursive_invocation: true) rescue nil
67
+ end
68
+
69
+ end
70
+
71
+ end
72
+ end
@@ -88,6 +88,7 @@ module HexaPDF
88
88
  autoload(:Standard14, 'hexapdf/font_loader/standard14')
89
89
  autoload(:FromConfiguration, 'hexapdf/font_loader/from_configuration')
90
90
  autoload(:FromFile, 'hexapdf/font_loader/from_file')
91
+ autoload(:VariantFromName, 'hexapdf/font_loader/variant_from_name')
91
92
 
92
93
  end
93
94
 
@@ -614,7 +614,7 @@ module HexaPDF
614
614
  # The font to be used, must be set to a valid font wrapper object before it can be used.
615
615
  #
616
616
  # HexaPDF::Composer handles this property specially in that it resolves a set string or array
617
- # to a font wrapper object before doing else with the style object.
617
+ # to a font wrapper object before doing anything else with the style object.
618
618
  #
619
619
  # This is the only style property without a default value!
620
620
  #
@@ -624,11 +624,12 @@ module HexaPDF
624
624
  #
625
625
  # #>pdf-composer100
626
626
  # composer.text("Helvetica", font: composer.document.fonts.add("Helvetica"))
627
- # composer.text("Courier", font: "Courier") # works only with composer
627
+ # composer.text("Courier", font: "Courier")
628
628
  #
629
629
  # helvetica_bold = composer.document.fonts.add("Helvetica", variant: :bold)
630
630
  # composer.text("Helvetica Bold", font: helvetica_bold)
631
- # composer.text("Courier Bold", font: ["Courier", variant: :bold]) # only composer
631
+ # composer.text("Courier Bold", font: "Courier bold")
632
+ # composer.text("Courier Bold also", font: ["Courier", variant: :bold])
632
633
 
633
634
  ##
634
635
  # :method: font_size
@@ -1413,7 +1414,7 @@ module HexaPDF
1413
1414
  # #>pdf-composer100
1414
1415
  # composer.text("This is some longer text that does appear in two lines.")
1415
1416
  # composer.text("This is some longer text that does not appear in two lines.",
1416
- # height: 15, text_overflow: :truncate)
1417
+ # height: 15, overflow: :truncate)
1417
1418
 
1418
1419
  [
1419
1420
  [:font, "raise HexaPDF::Error, 'No font set'"],
@@ -39,6 +39,7 @@ require 'hexapdf/error'
39
39
  require 'hexapdf/layout/style'
40
40
  require 'hexapdf/layout/text_fragment'
41
41
  require 'hexapdf/layout/text_layouter'
42
+ require 'hexapdf/type/acro_form/java_script_actions'
42
43
 
43
44
  module HexaPDF
44
45
  module Type
@@ -129,14 +130,20 @@ module HexaPDF
129
130
  # widget.background_color(1)
130
131
  # widget.marker_style(style: :circle, size: 0, color: 0)
131
132
  def create_check_box_appearances
132
- appearance_keys = @widget.appearance_dict&.normal_appearance&.value&.keys || []
133
- on_name = (appearance_keys - [:Off]).first
133
+ normal_appearance = @widget.appearance_dict&.normal_appearance
134
+ if !normal_appearance.kind_of?(HexaPDF::Dictionary) || normal_appearance.kind_of?(HexaPDF::Stream)
135
+ (@widget[:AP] ||= {})[:N] = {Off: nil}
136
+ normal_appearance = @widget[:AP][:N]
137
+ normal_appearance[@field[:V] == :Off ? :Yes : @field[:V]] = nil
138
+ end
139
+ on_name = (normal_appearance.value.keys - [:Off]).first
134
140
  unless on_name
135
141
  raise HexaPDF::Error, "Widget of button field doesn't define name for on state"
136
142
  end
137
143
 
138
144
  @widget[:AS] = (@field[:V] == on_name ? on_name : :Off)
139
145
  @widget.flag(:print)
146
+ @widget.unflag(:hidden)
140
147
 
141
148
  border_style = @widget.border_style
142
149
  marker_style = @widget.marker_style
@@ -206,6 +213,7 @@ module HexaPDF
206
213
 
207
214
  @widget[:AS] = :N
208
215
  @widget.flag(:print)
216
+ @widget.unflag(:hidden)
209
217
  rect = @widget[:Rect]
210
218
  rect.width = @document.config['acro_form.text_field.default_width'] if rect.width == 0
211
219
  if rect.height == 0
@@ -358,7 +366,7 @@ module HexaPDF
358
366
 
359
367
  # Draws a single line of text inside the widget's rectangle.
360
368
  def draw_single_line_text(canvas, width, height, style, padding)
361
- value, text_color = apply_javascript_formatting(@field.field_value)
369
+ value, text_color = JavaScriptActions.apply_format(@field.field_value, @field[:AA]&.[](:F))
362
370
  style.fill_color = text_color if text_color
363
371
  calculate_and_apply_font_size(value, style, width, height, padding)
364
372
  line = HexaPDF::Layout::Line.new(@document.layout.text_fragments(value, style: style))
@@ -500,79 +508,6 @@ module HexaPDF
500
508
  style.clear_cache
501
509
  end
502
510
 
503
- # Handles Javascript formatting routines for single-line text fields.
504
- #
505
- # Returns [value, nil_or_text_color] where value is the new, potentially adjusted field
506
- # value and the second argument is either +nil+ or the color that should be used for the
507
- # text value.
508
- def apply_javascript_formatting(value)
509
- format_action = @widget[:AA]&.[](:F)
510
- return [value, nil] unless format_action && format_action[:S] == :JavaScript
511
- if (match = AF_NUMBER_FORMAT_RE.match(format_action[:JS]))
512
- apply_af_number_format(value, match)
513
- else
514
- [value, nil]
515
- end
516
- end
517
-
518
- # Regular expression for matching the AFNumber_Format Javascript method.
519
- AF_NUMBER_FORMAT_RE = /
520
- \AAFNumber_Format\(
521
- \s*(?<ndec>\d+)\s*,
522
- \s*(?<sep_style>[0-3])\s*,
523
- \s*(?<neg_style>[0-3])\s*,
524
- \s*0\s*,
525
- \s*(?<currency_string>".*?")\s*,
526
- \s*(?<prepend>false|true)\s*
527
- \);\z
528
- /x
529
-
530
- # Implements the Javascript AFNumber_Format method.
531
- #
532
- # See:
533
- # - https://experienceleague.adobe.com/docs/experience-manager-learn/assets/FormsAPIReference.pdf
534
- # - https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsapiref/JS_API_AcroJS.html#printf
535
- def apply_af_number_format(value, match)
536
- value = value.to_f
537
- format = "%.#{match[:ndec]}f"
538
- text_color = 'black'
539
-
540
- currency_string = JSON.parse(match[:currency_string])
541
- format = (match[:prepend] == 'true' ? currency_string + format : format + currency_string)
542
-
543
- if value < 0
544
- value = value.abs
545
- case match[:neg_style]
546
- when '0' # MinusBlack
547
- format = "-#{format}"
548
- when '1' # Red
549
- text_color = 'red'
550
- when '2' # ParensBlack
551
- format = "(#{format})"
552
- when '3' # ParensRed
553
- format = "(#{format})"
554
- text_color = 'red'
555
- end
556
- end
557
-
558
- result = sprintf(format, value)
559
-
560
- # sep_style: 0=12,345.67, 1=12345.67, 2=12.345,67, 3=12345,67
561
- before_decimal_point, after_decimal_point = result.split('.')
562
- if match[:sep_style] == '0' || match[:sep_style] == '2'
563
- separator = (match[:sep_style] == '0' ? ',' : '.')
564
- before_decimal_point.gsub!(/\B(?=(\d\d\d)+(?:[^\d]|\z))/, separator)
565
- end
566
- result = if after_decimal_point
567
- decimal_point = (match[:sep_style] =~ /[01]/ ? '.' : ',')
568
- "#{before_decimal_point}#{decimal_point}#{after_decimal_point}"
569
- else
570
- before_decimal_point
571
- end
572
-
573
- [result, text_color]
574
- end
575
-
576
511
  end
577
512
 
578
513
  end
@@ -155,6 +155,12 @@ module HexaPDF
155
155
  field.value[name].nil? ? nil : field[name]
156
156
  end
157
157
 
158
+ # Wraps the given +field+ object inside the correct field class and returns the wrapped
159
+ # object.
160
+ def self.wrap(document, field)
161
+ document.wrap(field, type: :XXAcroFormField, subtype: inherited_value(field, :FT))
162
+ end
163
+
158
164
  # Form fields must always be indirect objects.
159
165
  def must_be_indirect?
160
166
  true
@@ -236,6 +242,14 @@ module HexaPDF
236
242
  kids.nil? || kids.empty? || kids.none? {|kid| kid.key?(:T) }
237
243
  end
238
244
 
245
+ # Returns self.
246
+ #
247
+ # This method is only here to make it easier to get the form field when the object may
248
+ # either be a form field or a field widget.
249
+ def form_field
250
+ self
251
+ end
252
+
239
253
  # Returns +true+ if the field contains an embedded widget.
240
254
  def embedded_widget?
241
255
  key?(:Subtype)
@@ -99,11 +99,11 @@ module HexaPDF
99
99
  document.pages.each do |page|
100
100
  page.each_annotation do |annot|
101
101
  if !annot.key?(:Parent) && annot.key?(:FT)
102
- result << document.wrap(annot, type: :XXAcroFormField, subtype: annot[:FT])
102
+ result << Field.wrap(document, annot)
103
103
  elsif annot.key?(:Parent)
104
104
  field = annot[:Parent]
105
105
  field = field[:Parent] while field[:Parent]
106
- result << document.wrap(field, type: :XXAcroFormField)
106
+ result << Field.wrap(document, field)
107
107
  end
108
108
  end
109
109
  end
@@ -129,8 +129,7 @@ module HexaPDF
129
129
  array.each_with_index do |field, index|
130
130
  next if field.nil?
131
131
  unless field.respond_to?(:type) && field.type == :XXAcroFormField
132
- array[index] = field = document.wrap(field, type: :XXAcroFormField,
133
- subtype: Field.inherited_value(field, :FT))
132
+ array[index] = field = Field.wrap(document, field)
134
133
  end
135
134
  if field.terminal_field?
136
135
  yield(field)
@@ -153,8 +152,7 @@ module HexaPDF
153
152
  name.split('.').each do |part|
154
153
  field = nil
155
154
  fields&.each do |f|
156
- f = document.wrap(f, type: :XXAcroFormField,
157
- subtype: Field.inherited_value(f, :FT))
155
+ f = Field.wrap(document, f)
158
156
  next unless f[:T] == part
159
157
  field = f
160
158
  fields = field[:Kids] unless field.terminal_field?
@@ -420,6 +418,26 @@ module HexaPDF
420
418
  not_flattened
421
419
  end
422
420
 
421
+ # Recalculates all form fields that have a calculate action applied (which are all fields
422
+ # listed in the /CO entry).
423
+ #
424
+ # If HexaPDF doesn't support a calculation method or an error occurs during calculation, the
425
+ # field value is not updated.
426
+ #
427
+ # Note that calculations are *not* done automatically when a form field's value changes
428
+ # since it would lead to possibly many calls to this actions. So first fill in all field
429
+ # values and then call this method.
430
+ #
431
+ # See: JavaScriptActions
432
+ def recalculate_fields
433
+ self[:CO]&.each do |field|
434
+ field = Field.wrap(document, field)
435
+ next unless field && (calculation_action = field[:AA]&.[](:C))
436
+ result = JavaScriptActions.calculate(self, calculation_action)
437
+ field.form_field.field_value = result if result
438
+ end
439
+ end
440
+
423
441
  private
424
442
 
425
443
  # Creates a new field with the full name +name+ and the field type +type+.
@@ -468,8 +486,7 @@ module HexaPDF
468
486
  end
469
487
  next false unless field.key?(:T) # Skip widgets
470
488
 
471
- field = document.wrap(field, type: :XXAcroFormField,
472
- subtype: Field.inherited_value(field, :FT))
489
+ field = Field.wrap(document, field)
473
490
  reject = false
474
491
  if field[:Parent] != parent
475
492
  yield("Parent entry of field (#{field.oid},#{field.gen}) invalid", true)