hexapdf 0.13.0 → 0.14.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/examples/019-acro_form.rb +41 -4
  4. data/lib/hexapdf/cli/split.rb +74 -14
  5. data/lib/hexapdf/document.rb +10 -4
  6. data/lib/hexapdf/layout/text_layouter.rb +2 -2
  7. data/lib/hexapdf/object.rb +22 -0
  8. data/lib/hexapdf/parser.rb +23 -1
  9. data/lib/hexapdf/pdf_array.rb +2 -2
  10. data/lib/hexapdf/type/acro_form/appearance_generator.rb +127 -27
  11. data/lib/hexapdf/type/acro_form/button_field.rb +5 -2
  12. data/lib/hexapdf/type/acro_form/choice_field.rb +64 -10
  13. data/lib/hexapdf/type/acro_form/form.rb +133 -10
  14. data/lib/hexapdf/type/acro_form/text_field.rb +68 -3
  15. data/lib/hexapdf/type/cid_font.rb +1 -1
  16. data/lib/hexapdf/type/font.rb +1 -1
  17. data/lib/hexapdf/type/font_simple.rb +1 -1
  18. data/lib/hexapdf/type/font_type0.rb +3 -3
  19. data/lib/hexapdf/type/form.rb +4 -1
  20. data/lib/hexapdf/type/page.rb +5 -5
  21. data/lib/hexapdf/utils/object_hash.rb +0 -1
  22. data/lib/hexapdf/version.rb +1 -1
  23. data/test/hexapdf/layout/test_text_layouter.rb +9 -1
  24. data/test/hexapdf/test_document.rb +14 -6
  25. data/test/hexapdf/test_object.rb +27 -0
  26. data/test/hexapdf/test_parser.rb +46 -0
  27. data/test/hexapdf/test_pdf_array.rb +1 -1
  28. data/test/hexapdf/test_writer.rb +2 -2
  29. data/test/hexapdf/type/acro_form/test_appearance_generator.rb +286 -34
  30. data/test/hexapdf/type/acro_form/test_button_field.rb +15 -0
  31. data/test/hexapdf/type/acro_form/test_choice_field.rb +92 -9
  32. data/test/hexapdf/type/acro_form/test_form.rb +83 -11
  33. data/test/hexapdf/type/acro_form/test_text_field.rb +75 -1
  34. data/test/hexapdf/type/test_form.rb +7 -0
  35. data/test/hexapdf/utils/test_object_hash.rb +5 -0
  36. data/test/test_helper.rb +2 -0
  37. metadata +4 -3
@@ -228,10 +228,12 @@ module HexaPDF
228
228
  #
229
229
  # The created appearance streams depend on the actual type of the button field. See
230
230
  # AppearanceGenerator for the details.
231
- def create_appearances
231
+ #
232
+ # By setting +force+ to +true+ the creation of the appearances can be forced.
233
+ def create_appearances(force: false)
232
234
  appearance_generator_class = document.config.constantize('acro_form.appearance_generator')
233
235
  each_widget do |widget|
234
- next if widget.appearance?
236
+ next if !force && widget.appearance?
235
237
  if check_box?
236
238
  appearance_generator_class.new(widget).create_check_box_appearances
237
239
  elsif radio_button?
@@ -245,6 +247,7 @@ module HexaPDF
245
247
  # Updates the widgets so that they reflect the current field value.
246
248
  def update_widgets
247
249
  return if push_button?
250
+ create_appearances
248
251
  value = self[:V]
249
252
  each_widget do |widget|
250
253
  widget[:AS] = (widget.appearance&.normal_appearance&.value&.key?(value) ? value : :Off)
@@ -122,17 +122,24 @@ module HexaPDF
122
122
  end
123
123
 
124
124
  # Sets the field value to the given string or array of strings.
125
+ #
126
+ # The dictionary field /I is also modified to correctly represent the selected item(s).
125
127
  def field_value=(value)
126
128
  items = option_items
127
- all_included = [value].flatten.all? {|v| items.include?(v) }
129
+ array_value = [value].flatten
130
+ all_included = array_value.all? {|v| items.include?(v) }
128
131
  self[:V] = if (combo_box? && value.kind_of?(String) &&
129
- (flagged?(:edit) || all_included)) ||
130
- (list_box? && all_included &&
131
- (value.kind_of?(String) || flagged?(:multi_select)))
132
+ (flagged?(:edit) || all_included))
133
+ delete(:I)
132
134
  value
135
+ elsif list_box? && all_included &&
136
+ (value.kind_of?(String) || flagged?(:multi_select))
137
+ self[:I] = array_value.map {|val| items.index(val) }.sort!
138
+ array_value.length == 1 ? value : array_value
133
139
  else
134
140
  @document.config['acro_form.on_invalid_value'].call(self, value)
135
141
  end
142
+ update_widgets
136
143
  end
137
144
 
138
145
  # Returns the default field value.
@@ -155,13 +162,47 @@ module HexaPDF
155
162
  end
156
163
 
157
164
  # Returns the array with the available option items.
165
+ #
166
+ # Note that this *only* returns the option items themselves! For getting the export values,
167
+ # the #export_values method has to be used.
158
168
  def option_items
159
- key?(:Opt) ? process_value(self[:Opt]) : self[:Opt] ||= []
169
+ key?(:Opt) ? process_value(self[:Opt].map {|i| i.kind_of?(Array) ? i[1] : i }) : []
170
+ end
171
+
172
+ # Returns the export values of the option items.
173
+ #
174
+ # If you need the display strings (as in most cases), use the #option_items method.
175
+ def export_values
176
+ key?(:Opt) ? process_value(self[:Opt].map {|i| i.kind_of?(Array) ? i[0] : i }) : []
160
177
  end
161
178
 
162
179
  # Sets the array with the available option items to the given value.
180
+ #
181
+ # Each entry in the array may either be a string representing the text to be displayed. Or
182
+ # an array of two strings where the first describes the export value (to be used when
183
+ # exporting form field data from the document) and the second is the display value.
184
+ #
185
+ # See: #option_items, #export_values
163
186
  def option_items=(value)
164
- self[:Opt] = value
187
+ self[:Opt] = if flagged?(:sort)
188
+ value.sort_by {|i| process_value(i.kind_of?(Array) ? i[1] : i) }
189
+ else
190
+ value
191
+ end
192
+ end
193
+
194
+ # Returns the index of the first visible option item of a list box.
195
+ def list_box_top_index
196
+ self[:TI]
197
+ end
198
+
199
+ # Makes the option item referred to via the given +index+ the first visible option item of a
200
+ # list box.
201
+ def list_box_top_index=(index)
202
+ if index < 0 || !key?(:Opt) || index >= self[:Opt].length
203
+ raise ArgumentError, "Index out of range for the set option items"
204
+ end
205
+ self[:TI] = index
165
206
  end
166
207
 
167
208
  # Returns the concrete choice field type, either :list_box, :combo_box or
@@ -178,19 +219,32 @@ module HexaPDF
178
219
  #
179
220
  # For information on how this is done see AppearanceGenerator.
180
221
  #
181
- # Note that an appearance for a choice field widget is *always* created even if there is an
182
- # existing one to make sure the current field value is properly represented.
183
- def create_appearances
222
+ # Note that no new appearances are created if the dictionary fields involved in the creation
223
+ # of the appearance stream have not been changed between invocations.
224
+ #
225
+ # By setting +force+ to +true+ the creation of the appearances can be forced.
226
+ def create_appearances(force: false)
227
+ current_appearance_state = [self[:V], self[:I], self[:Opt], self[:TI]]
228
+
184
229
  appearance_generator_class = document.config.constantize('acro_form.appearance_generator')
185
230
  each_widget do |widget|
231
+ next if !force && widget.cached?(:appearance_state) &&
232
+ widget.cache(:appearance_state) == current_appearance_state
233
+
234
+ widget.cache(:appearance_state, current_appearance_state, update: true)
186
235
  if combo_box?
187
236
  appearance_generator_class.new(widget).create_combo_box_appearances
188
237
  else
189
- raise HexaPDF::Error, "List boxes not yet supported"
238
+ appearance_generator_class.new(widget).create_list_box_appearances
190
239
  end
191
240
  end
192
241
  end
193
242
 
243
+ # Updates the widgets so that they reflect the current field value.
244
+ def update_widgets
245
+ create_appearances
246
+ end
247
+
194
248
  private
195
249
 
196
250
  # Uses the HexaPDF::DictionaryFields::StringConverter to process the value (a string or an
@@ -153,8 +153,83 @@ module HexaPDF
153
153
  #
154
154
  # The +name+ may contain dots to signify a field hierarchy. If so, the referenced parent
155
155
  # fields must already exist. If it doesn't contain dots, a top-level field is created.
156
- def create_text_field(name)
157
- create_field(name, :Tx)
156
+ #
157
+ # The optional keyword arguments allow setting often used properties of the field:
158
+ #
159
+ # +font+::
160
+ # The font that should be used for the text of the field. If +font_size+ is specified
161
+ # but +font+ isn't, the font Helvetica is used.
162
+ #
163
+ # +font_size+::
164
+ # The font size that should be used. If +font+ is specified but +font_size+ isn't, font
165
+ # size defaults to 0 (= auto-sizing).
166
+ #
167
+ # +align+::
168
+ # The alignment of the text, either :left, :center or :right.
169
+ def create_text_field(name, font: nil, font_size: nil, align: nil)
170
+ create_field(name, :Tx) do |field|
171
+ apply_variable_text_properties(field, font: font, font_size: font_size, align: align)
172
+ end
173
+ end
174
+
175
+ # Creates a new multiline text field with the given name and adds it to the form.
176
+ #
177
+ # The +name+ may contain dots to signify a field hierarchy. If so, the referenced parent
178
+ # fields must already exist. If it doesn't contain dots, a top-level field is created.
179
+ #
180
+ # The optional keyword arguments allow setting often used properties of the field, see
181
+ # #create_text_field for details.
182
+ def create_multiline_text_field(name, font: nil, font_size: nil, align: nil)
183
+ create_field(name, :Tx) do |field|
184
+ field.initialize_as_multiline_text_field
185
+ apply_variable_text_properties(field, font: font, font_size: font_size, align: align)
186
+ end
187
+ end
188
+
189
+ # Creates a new comb text field with the given name and adds it to the form.
190
+ #
191
+ # The +max_chars+ argument defines the maximum number of characters the comb text field can
192
+ # accommodate.
193
+ #
194
+ # The +name+ may contain dots to signify a field hierarchy. If so, the referenced parent
195
+ # fields must already exist. If it doesn't contain dots, a top-level field is created.
196
+ #
197
+ # The optional keyword arguments allow setting often used properties of the field, see
198
+ # #create_text_field for details.
199
+ def create_comb_text_field(name, max_chars:, font: nil, font_size: nil, align: nil)
200
+ create_field(name, :Tx) do |field|
201
+ field.initialize_as_comb_text_field
202
+ apply_variable_text_properties(field, font: font, font_size: font_size, align: align)
203
+ field[:MaxLen] = max_chars
204
+ end
205
+ end
206
+
207
+ # Creates a new file select field with the given name and adds it to the form.
208
+ #
209
+ # The +name+ may contain dots to signify a field hierarchy. If so, the referenced parent
210
+ # fields must already exist. If it doesn't contain dots, a top-level field is created.
211
+ #
212
+ # The optional keyword arguments allow setting often used properties of the field, see
213
+ # #create_text_field for details.
214
+ def create_file_select_field(name, font: nil, font_size: nil, align: nil)
215
+ create_field(name, :Tx) do |field|
216
+ field.initialize_as_file_select_field
217
+ apply_variable_text_properties(field, font: font, font_size: font_size, align: align)
218
+ end
219
+ end
220
+
221
+ # Creates a new password field with the given name and adds it to the form.
222
+ #
223
+ # The +name+ may contain dots to signify a field hierarchy. If so, the referenced parent
224
+ # fields must already exist. If it doesn't contain dots, a top-level field is created.
225
+ #
226
+ # The optional keyword arguments allow setting often used properties of the field, see
227
+ # #create_text_field for details.
228
+ def create_password_field(name, font: nil, font_size: nil, align: nil)
229
+ create_field(name, :Tx) do |field|
230
+ field.initialize_as_password_field
231
+ apply_variable_text_properties(field, font: font, font_size: font_size, align: align)
232
+ end
158
233
  end
159
234
 
160
235
  # Creates a new check box with the given name and adds it to the form.
@@ -162,7 +237,7 @@ module HexaPDF
162
237
  # The +name+ may contain dots to signify a field hierarchy. If so, the referenced parent
163
238
  # fields must already exist. If it doesn't contain dots, a top-level field is created.
164
239
  def create_check_box(name)
165
- create_field(name, :Btn).tap(&:initialize_as_check_box)
240
+ create_field(name, :Btn, &:initialize_as_check_box)
166
241
  end
167
242
 
168
243
  # Creates a radio button with the given name and adds it to the form.
@@ -170,23 +245,58 @@ module HexaPDF
170
245
  # The +name+ may contain dots to signify a field hierarchy. If so, the referenced parent
171
246
  # fields must already exist. If it doesn't contain dots, a top-level field is created.
172
247
  def create_radio_button(name)
173
- create_field(name, :Btn).tap(&:initialize_as_radio_button)
248
+ create_field(name, :Btn, &:initialize_as_radio_button)
174
249
  end
175
250
 
176
251
  # Creates a combo box with the given name and adds it to the form.
177
252
  #
178
253
  # The +name+ may contain dots to signify a field hierarchy. If so, the referenced parent
179
254
  # fields must already exist. If it doesn't contain dots, a top-level field is created.
180
- def create_combo_box(name)
181
- create_field(name, :Ch).tap(&:initialize_as_combo_box)
255
+ #
256
+ # The optional keyword arguments allow setting often used properties of the field:
257
+ #
258
+ # +option_items+::
259
+ # Specifies the values of the list box.
260
+ #
261
+ # +editable+::
262
+ # If set to +true+, the combo box allows entering an arbitrary value in addition to
263
+ # selecting one of the provided option items.
264
+ #
265
+ # +font+, +font_size+ and +align+::
266
+ # See #create_text_field
267
+ def create_combo_box(name, option_items: nil, editable: nil, font: nil, font_size: nil,
268
+ align: nil)
269
+ create_field(name, :Ch) do |field|
270
+ field.initialize_as_combo_box
271
+ field.option_items = option_items if option_items
272
+ field.flag(:edit) if editable
273
+ apply_variable_text_properties(field, font: font, font_size: font_size, align: align)
274
+ end
182
275
  end
183
276
 
184
277
  # Creates a list box with the given name and adds it to the form.
185
278
  #
186
279
  # The +name+ may contain dots to signify a field hierarchy. If so, the referenced parent
187
280
  # fields must already exist. If it doesn't contain dots, a top-level field is created.
188
- def create_list_box(name)
189
- create_field(name, :Ch).tap(&:initialize_as_list_box)
281
+ #
282
+ # The optional keyword arguments allow setting often used properties of the field:
283
+ #
284
+ # +option_items+::
285
+ # Specifies the values of the list box.
286
+ #
287
+ # +multi_select+::
288
+ # If set to +true+, the list box allows selecting multiple items instead of only one.
289
+ #
290
+ # +font+, +font_size+ and +align+::
291
+ # See #create_text_field.
292
+ def create_list_box(name, option_items: nil, multi_select: nil, font: nil, font_size: nil,
293
+ align: nil)
294
+ create_field(name, :Ch) do |field|
295
+ field.initialize_as_list_box
296
+ field.option_items = option_items if option_items
297
+ field.flag(:multi_select) if multi_select
298
+ apply_variable_text_properties(field, font: font, font_size: font_size, align: align)
299
+ end
190
300
  end
191
301
 
192
302
  # Returns the dictionary containing the default resources for form field appearance streams.
@@ -213,9 +323,11 @@ module HexaPDF
213
323
  end
214
324
 
215
325
  # Creates the appearances for all widgets of all terminal fields if they don't exist.
216
- def create_appearances
326
+ #
327
+ # If +force+ is +true+, new appearances are created even if there are existing ones.
328
+ def create_appearances(force: false)
217
329
  each_field do |field|
218
- field.create_appearances if field.respond_to?(:create_appearances)
330
+ field.create_appearances(force: force) if field.respond_to?(:create_appearances)
219
331
  end
220
332
  end
221
333
 
@@ -246,9 +358,20 @@ module HexaPDF
246
358
  else
247
359
  (self[:Fields] ||= []) << field
248
360
  end
361
+
362
+ yield(field)
363
+
249
364
  field
250
365
  end
251
366
 
367
+ # Applies the given variable field properties to the field.
368
+ def apply_variable_text_properties(field, font: nil, font_size: nil, align: nil)
369
+ if font || font_size
370
+ field.set_default_appearance_string(font: font || 'Helvetica', font_size: font_size || 0)
371
+ end
372
+ field.text_alignment(align) if align
373
+ end
374
+
252
375
  def perform_validation # :nodoc:
253
376
  super
254
377
 
@@ -44,6 +44,9 @@ module HexaPDF
44
44
  # AcroForm text fields provide a box or space to fill-in data entered from keyboard. The text
45
45
  # may be restricted to a single line or can span multiple lines.
46
46
  #
47
+ # A special type of single-line text field is the comb text field. This type of field divides
48
+ # the existing space into /MaxLen equally spaced positions.
49
+ #
47
50
  # == Type Specific Field Flags
48
51
  #
49
52
  # :multiline:: If set, the text field may contain multiple lines.
@@ -88,6 +91,63 @@ module HexaPDF
88
91
  }
89
92
  ).freeze
90
93
 
94
+ # Initializes the text field to be a multiline text field.
95
+ #
96
+ # This method should only be called directly after creating a new text field because it
97
+ # doesn't completely reset the object.
98
+ def initialize_as_multiline_text_field
99
+ flag(:multiline)
100
+ unflag(:file_select, :comb, :password)
101
+ end
102
+
103
+ # Initializes the text field to be a comb text field.
104
+ #
105
+ # This method should only be called directly after creating a new text field because it
106
+ # doesn't completely reset the object.
107
+ def initialize_as_comb_text_field
108
+ flag(:comb)
109
+ unflag(:file_select, :multiline, :password)
110
+ end
111
+
112
+ # Initializes the text field to be a password field.
113
+ #
114
+ # This method should only be called directly after creating a new text field because it
115
+ # doesn't completely reset the object.
116
+ def initialize_as_password_field
117
+ delete(:V)
118
+ flag(:password)
119
+ unflag(:comb, :multiline, :file_select)
120
+ end
121
+
122
+ # Initializes the text field to be a file select field.
123
+ #
124
+ # This method should only be called directly after creating a new text field because it
125
+ # doesn't completely reset the object.
126
+ def initialize_as_file_select_field
127
+ flag(:file_select)
128
+ unflag(:comb, :multiline, :password)
129
+ end
130
+
131
+ # Returns +true+ if this field is a multiline text field.
132
+ def multiline_text_field?
133
+ flagged?(:multiline) && !(flagged?(:file_select) || flagged?(:comb) || flagged?(:password))
134
+ end
135
+
136
+ # Returns +true+ if this field is a comb text field.
137
+ def comb_text_field?
138
+ flagged?(:comb) && !(flagged?(:file_select) || flagged?(:multiline) || flagged?(:password))
139
+ end
140
+
141
+ # Returns +true+ if this field is a password field.
142
+ def password_field?
143
+ flagged?(:password) && !(flagged?(:file_select) || flagged?(:multiline) || flagged?(:comb))
144
+ end
145
+
146
+ # Returns +true+ if this field is a file select field.
147
+ def file_select_field?
148
+ flagged?(:file_select) && !(flagged?(:password) || flagged?(:multiline) || flagged?(:comb))
149
+ end
150
+
91
151
  # Returns the field value, i.e. the text contents of the field, or +nil+ if no value is set.
92
152
  #
93
153
  # Note that modifying the returned value *might not* modify the text contents in case it is
@@ -147,11 +207,16 @@ module HexaPDF
147
207
  #
148
208
  # For information on how this is done see AppearanceGenerator.
149
209
  #
150
- # Note that an appearance for a text field widget is *always* created even if there is an
151
- # existing one to make sure the current field value is properly represented.
152
- def create_appearances
210
+ # Note that no new appearances are created if the field value hasn't changed between
211
+ # invocations.
212
+ #
213
+ # By setting +force+ to +true+ the creation of the appearances can be forced.
214
+ def create_appearances(force: false)
215
+ current_value = field_value
153
216
  appearance_generator_class = document.config.constantize('acro_form.appearance_generator')
154
217
  each_widget do |widget|
218
+ next if !force && widget.cached?(:last_value) && widget.cache(:last_value) == current_value
219
+ widget.cache(:last_value, current_value, update: true)
155
220
  appearance_generator_class.new(widget).create_text_appearances
156
221
  end
157
222
  end
@@ -95,7 +95,7 @@ module HexaPDF
95
95
  #
96
96
  # See: PDF1.7 s9.7.4.3
97
97
  def widths
98
- document.cache(@data, :widths) do
98
+ cache(:widths) do
99
99
  result = {}
100
100
  index = 0
101
101
  array = self[:W] || []
@@ -102,7 +102,7 @@ module HexaPDF
102
102
 
103
103
  # Parses and caches the ToUnicode CMap.
104
104
  def to_unicode_cmap
105
- document.cache(@data, :to_unicode_cmap) do
105
+ cache(:to_unicode_cmap) do
106
106
  if key?(:ToUnicode)
107
107
  HexaPDF::Font::CMap.parse(self[:ToUnicode].stream)
108
108
  else
@@ -57,7 +57,7 @@ module HexaPDF
57
57
  #
58
58
  # Note that the encoding is cached internally when accessed the first time.
59
59
  def encoding
60
- document.cache(@data, :encoding) do
60
+ cache(:encoding) do
61
61
  case (val = self[:Encoding])
62
62
  when Symbol
63
63
  encoding = HexaPDF::Font::Encoding.for_name(val)