hexapdf 0.27.0 → 0.28.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/examples/019-acro_form.rb +14 -3
  4. data/examples/023-images.rb +30 -0
  5. data/lib/hexapdf/cli/info.rb +5 -1
  6. data/lib/hexapdf/cli/inspect.rb +2 -2
  7. data/lib/hexapdf/cli/split.rb +2 -2
  8. data/lib/hexapdf/configuration.rb +1 -2
  9. data/lib/hexapdf/content/canvas.rb +8 -3
  10. data/lib/hexapdf/dictionary.rb +1 -5
  11. data/lib/hexapdf/document.rb +6 -10
  12. data/lib/hexapdf/filter/ascii85_decode.rb +1 -1
  13. data/lib/hexapdf/importer.rb +32 -27
  14. data/lib/hexapdf/layout/list_box.rb +1 -5
  15. data/lib/hexapdf/object.rb +5 -0
  16. data/lib/hexapdf/parser.rb +13 -0
  17. data/lib/hexapdf/revision.rb +15 -12
  18. data/lib/hexapdf/revisions.rb +4 -0
  19. data/lib/hexapdf/tokenizer.rb +14 -8
  20. data/lib/hexapdf/type/acro_form/appearance_generator.rb +174 -128
  21. data/lib/hexapdf/type/acro_form/button_field.rb +5 -3
  22. data/lib/hexapdf/type/acro_form/choice_field.rb +2 -0
  23. data/lib/hexapdf/type/acro_form/field.rb +11 -5
  24. data/lib/hexapdf/type/acro_form/form.rb +33 -7
  25. data/lib/hexapdf/type/acro_form/signature_field.rb +2 -0
  26. data/lib/hexapdf/type/acro_form/text_field.rb +12 -2
  27. data/lib/hexapdf/type/annotations/widget.rb +3 -0
  28. data/lib/hexapdf/type/font_true_type.rb +14 -0
  29. data/lib/hexapdf/type/object_stream.rb +2 -2
  30. data/lib/hexapdf/type/outline.rb +1 -1
  31. data/lib/hexapdf/type/page.rb +56 -46
  32. data/lib/hexapdf/version.rb +1 -1
  33. data/lib/hexapdf/writer.rb +2 -3
  34. data/test/hexapdf/content/test_canvas.rb +5 -0
  35. data/test/hexapdf/document/test_pages.rb +2 -2
  36. data/test/hexapdf/encryption/test_aes.rb +1 -1
  37. data/test/hexapdf/filter/test_predictor.rb +0 -1
  38. data/test/hexapdf/layout/test_box.rb +2 -1
  39. data/test/hexapdf/layout/test_column_box.rb +1 -1
  40. data/test/hexapdf/layout/test_list_box.rb +1 -1
  41. data/test/hexapdf/test_document.rb +2 -8
  42. data/test/hexapdf/test_importer.rb +13 -6
  43. data/test/hexapdf/test_parser.rb +17 -0
  44. data/test/hexapdf/test_revision.rb +15 -14
  45. data/test/hexapdf/test_revisions.rb +43 -0
  46. data/test/hexapdf/test_stream.rb +1 -1
  47. data/test/hexapdf/test_tokenizer.rb +3 -4
  48. data/test/hexapdf/test_writer.rb +3 -3
  49. data/test/hexapdf/type/acro_form/test_appearance_generator.rb +135 -56
  50. data/test/hexapdf/type/acro_form/test_button_field.rb +6 -1
  51. data/test/hexapdf/type/acro_form/test_choice_field.rb +4 -0
  52. data/test/hexapdf/type/acro_form/test_field.rb +4 -4
  53. data/test/hexapdf/type/acro_form/test_form.rb +18 -0
  54. data/test/hexapdf/type/acro_form/test_signature_field.rb +4 -0
  55. data/test/hexapdf/type/acro_form/test_text_field.rb +13 -0
  56. data/test/hexapdf/type/signature/common.rb +3 -1
  57. data/test/hexapdf/type/test_font_true_type.rb +20 -0
  58. data/test/hexapdf/type/test_object_stream.rb +2 -1
  59. data/test/hexapdf/type/test_outline.rb +3 -0
  60. data/test/hexapdf/type/test_page.rb +67 -30
  61. data/test/hexapdf/type/test_page_tree_node.rb +4 -2
  62. metadata +46 -3
@@ -34,6 +34,7 @@
34
34
  # commercial licenses are available at <https://gettalong.at/hexapdf/>.
35
35
  #++
36
36
 
37
+ require 'json'
37
38
  require 'hexapdf/error'
38
39
  require 'hexapdf/layout/style'
39
40
  require 'hexapdf/layout/text_fragment'
@@ -74,12 +75,10 @@ module HexaPDF
74
75
  def create_appearances
75
76
  case @field.field_type
76
77
  when :Btn
77
- if @field.check_box?
78
- create_check_box_appearances
79
- elsif @field.radio_button?
80
- create_radio_button_appearances
78
+ if @field.push_button?
79
+ create_push_button_appearances
81
80
  else
82
- raise HexaPDF::Error, "Unsupported button field type"
81
+ create_check_box_appearances
83
82
  end
84
83
  when :Tx, :Ch
85
84
  create_text_appearances
@@ -88,23 +87,24 @@ module HexaPDF
88
87
  end
89
88
  end
90
89
 
91
- # Creates the appropriate appearances for check boxes.
90
+ # Creates the appropriate appearances for check boxes and radio buttons.
92
91
  #
93
- # The unchecked box is always represented by the appearance with the key /Off. If there is
94
- # more than one other key besides the /Off key, the first one is used for the appearance of
95
- # the checked box.
92
+ # The unchecked box or unselected radio button is always represented by the appearance with
93
+ # the key /Off. If there is more than one other key besides the /Off key, the first one is
94
+ # used for the appearance of the checked box or selected radio button.
96
95
  #
97
- # For unchecked boxes an empty rectangle is drawn. When checked, a symbol from the
98
- # ZapfDingbats font is placed inside the rectangle. How this is exactly done depends on the
99
- # following values:
96
+ # For unchecked boxes an empty rectangle is drawn. Similarly, for unselected radio buttons
97
+ # an empty circle (if the marker is :circle) or rectangle is drawn. When checked or
98
+ # selected, a symbol from the ZapfDingbats font is placed inside. How this is exactly done
99
+ # depends on the following values:
100
100
  #
101
101
  # * The widget's rectangle /Rect must be defined. If the height and/or width of the
102
102
  # rectangle are zero, they are based on the configuration option
103
103
  # +acro_form.default_font_size+ and widget's border width. In such a case the rectangle is
104
104
  # appropriately updated.
105
105
  #
106
- # * The line width, style and color of the rectangle are taken from the widget's border
107
- # style. See HexaPDF::Type::Annotations::Widget#border_style.
106
+ # * The line width, style and color of the cirle/rectangle are taken from the widget's
107
+ # border style. See HexaPDF::Type::Annotations::Widget#border_style.
108
108
  #
109
109
  # * The background color is determined by the widget's background color. See
110
110
  # HexaPDF::Type::Annotations::Widget#background_color.
@@ -114,94 +114,66 @@ module HexaPDF
114
114
  #
115
115
  # Examples:
116
116
  #
117
+ # # check box: default appearance
117
118
  # widget.border_style(color: 0)
118
119
  # widget.background_color(1)
119
120
  # widget.marker_style(style: :check, size: 0, color: 0)
120
- # # => default appearance
121
121
  #
122
+ # # check box: no visible rectangle, gray background, cross mark when checked
122
123
  # widget.border_style(color: :transparent, width: 2)
123
124
  # widget.background_color(0.7)
124
125
  # widget.marker_style(style: :cross)
125
- # # => no visible rectangle, gray background, cross mark when checked
126
- def create_check_box_appearances
127
- appearance_keys = @widget.appearance_dict&.normal_appearance&.value&.keys || []
128
- on_name = (appearance_keys - [:Off]).first
129
- unless on_name
130
- raise HexaPDF::Error, "Widget of check box doesn't define name for on state"
131
- end
132
- border_style = @widget.border_style
133
- border_width = border_style.width
134
-
135
- rect = update_widget(@field[:V], border_width)
136
-
137
- off_form = @widget.appearance_dict.normal_appearance[:Off] =
138
- @document.add({Type: :XObject, Subtype: :Form, BBox: [0, 0, rect.width, rect.height]})
139
- apply_background_and_border(border_style, off_form.canvas)
140
-
141
- on_form = @widget.appearance_dict.normal_appearance[on_name] =
142
- @document.add({Type: :XObject, Subtype: :Form, BBox: [0, 0, rect.width, rect.height]})
143
- canvas = on_form.canvas
144
- apply_background_and_border(border_style, canvas)
145
- canvas.save_graphics_state do
146
- draw_marker(canvas, rect, border_width, @widget.marker_style)
147
- end
148
- end
149
-
150
- # Creates the appropriate appearances for radio buttons.
151
- #
152
- # For unselected radio buttons an empty circle (if the marker is :circle) or rectangle is
153
- # drawn inside the widget annotation's rectangle. When selected, a symbol from the
154
- # ZapfDingbats font is placed inside. How this is exactly done depends on the following
155
- # values:
156
- #
157
- # * The widget's rectangle /Rect must be defined. If the height and/or width of the
158
- # rectangle are zero, they are based on the configuration option
159
- # +acro_form.default_font_size+ and the widget's border width. In such a case the
160
- # rectangle is appropriately updated.
161
- #
162
- # * The line width, style and color of the circle/rectangle are taken from the widget's
163
- # border style. See HexaPDF::Type::Annotations::Widget#border_style.
164
- #
165
- # * The background color is determined by the widget's background color. See
166
- # HexaPDF::Type::Annotations::Widget#background_color.
167
- #
168
- # * The symbol (marker) as well as its size and color are determined by the marker style of
169
- # the widget. See HexaPDF::Type::Annotations::Widget#marker_style for details.
170
- #
171
- # Examples:
172
126
  #
127
+ # # radio button: default appearance
173
128
  # widget.border_style(color: 0)
174
129
  # widget.background_color(1)
175
130
  # widget.marker_style(style: :circle, size: 0, color: 0)
176
- # # => default appearance
177
- def create_radio_button_appearances
131
+ def create_check_box_appearances
178
132
  appearance_keys = @widget.appearance_dict&.normal_appearance&.value&.keys || []
179
133
  on_name = (appearance_keys - [:Off]).first
180
134
  unless on_name
181
- raise HexaPDF::Error, "Widget of radio button doesn't define name for on state"
135
+ raise HexaPDF::Error, "Widget of button field doesn't define name for on state"
182
136
  end
183
137
 
138
+ @widget[:AS] = (@field[:V] == on_name ? on_name : :Off)
139
+ @widget.flag(:print)
140
+
184
141
  border_style = @widget.border_style
185
142
  marker_style = @widget.marker_style
143
+ circular = (@field.radio_button? && marker_style.style == :circle)
144
+
145
+ default_font_size = @document.config['acro_form.default_font_size']
146
+ rect = @widget[:Rect]
147
+ rect.width = default_font_size + 2 * border_style.width if rect.width == 0
148
+ rect.height = default_font_size + 2 * border_style.width if rect.height == 0
186
149
 
187
- rect = update_widget(@field[:V] == on_name ? on_name : :Off, border_style.width)
150
+ width, height, matrix = perform_rotation(rect.width, rect.height)
188
151
 
189
152
  off_form = @widget.appearance_dict.normal_appearance[:Off] =
190
- @document.add({Type: :XObject, Subtype: :Form, BBox: [0, 0, rect.width, rect.height]})
191
- apply_background_and_border(border_style, off_form.canvas,
192
- circular: marker_style.style == :circle)
153
+ @document.add({Type: :XObject, Subtype: :Form, BBox: [0, 0, width, height],
154
+ Matrix: matrix})
155
+ apply_background_and_border(border_style, off_form.canvas, circular: circular)
193
156
 
194
157
  on_form = @widget.appearance_dict.normal_appearance[on_name] =
195
- @document.add({Type: :XObject, Subtype: :Form, BBox: [0, 0, rect.width, rect.height]})
158
+ @document.add({Type: :XObject, Subtype: :Form, BBox: [0, 0, width, height],
159
+ Matrix: matrix})
196
160
  canvas = on_form.canvas
197
- apply_background_and_border(border_style, canvas,
198
- circular: marker_style.style == :circle)
161
+ apply_background_and_border(border_style, canvas, circular: circular)
199
162
  canvas.save_graphics_state do
200
- draw_marker(canvas, rect, border_style.width, @widget.marker_style)
163
+ draw_marker(canvas, width, height, border_style.width, marker_style)
201
164
  end
202
165
  end
203
166
 
204
- # Creates the appropriate appearances for text fields.
167
+ alias create_radio_button_appearances create_check_box_appearances
168
+
169
+ # Creates the appropriate appearances for push buttons.
170
+ #
171
+ # This is currently a dummy implementation raising an error.
172
+ def create_push_button_appearances
173
+ raise HexaPDF::Error, "Push button appearance generation not yet supported"
174
+ end
175
+
176
+ # Creates the appropriate appearances for text fields, combo box fields and list box fields.
205
177
  #
206
178
  # The following describes how the appearance is built:
207
179
  #
@@ -242,31 +214,33 @@ module HexaPDF
242
214
  rect.height = style.scaled_y_max - style.scaled_y_min + 2 * padding
243
215
  end
244
216
 
217
+ width, height, matrix = perform_rotation(rect.width, rect.height)
218
+
245
219
  form = (@widget[:AP] ||= {})[:N] ||= @document.add({Type: :XObject, Subtype: :Form})
246
220
  # Wrap existing object in Form class in case the PDF writer didn't include the /Subtype
247
221
  # key; we can do this since we know this has to be a Form object
248
222
  form = @document.wrap(form, type: :XObject, subtype: :Form) unless form[:Subtype] == :Form
249
- form.value.replace({Type: :XObject, Subtype: :Form, BBox: [0, 0, rect.width, rect.height]})
223
+ form.value.replace({Type: :XObject, Subtype: :Form, BBox: [0, 0, width, height],
224
+ Matrix: matrix, Resources: HexaPDF::Object.deep_copy(default_resources)})
250
225
  form.contents = ''
251
- form[:Resources] = HexaPDF::Object.deep_copy(default_resources)
252
226
 
253
227
  canvas = form.canvas
254
228
  apply_background_and_border(border_style, canvas)
255
- style.font_size = calculate_font_size(font, font_size, rect, border_style)
229
+ style.font_size = calculate_font_size(font, font_size, height, border_style)
256
230
  style.clear_cache
257
231
 
258
232
  canvas.marked_content_sequence(:Tx) do
259
233
  if @field.field_value || @field.concrete_field_type == :list_box
260
234
  canvas.save_graphics_state do
261
- canvas.rectangle(padding, padding, rect.width - 2 * padding,
262
- rect.height - 2 * padding).clip_path.end_path
235
+ canvas.rectangle(padding, padding, width - 2 * padding,
236
+ height - 2 * padding).clip_path.end_path
263
237
  case @field.concrete_field_type
264
238
  when :multiline_text_field
265
- draw_multiline_text(canvas, rect, style, padding)
239
+ draw_multiline_text(canvas, width, height, style, padding)
266
240
  when :list_box
267
- draw_list_box(canvas, rect, style, padding)
241
+ draw_list_box(canvas, width, height, style, padding)
268
242
  else
269
- draw_single_line_text(canvas, rect, style, padding)
243
+ draw_single_line_text(canvas, width, height, style, padding)
270
244
  end
271
245
  end
272
246
  end
@@ -278,23 +252,20 @@ module HexaPDF
278
252
 
279
253
  private
280
254
 
281
- # Updates the widget and returns its (possibly modified) rectangle.
282
- #
283
- # The following changes are made:
284
- #
285
- # * Sets the appearance state to +appearance_state+.
286
- # * Sets the :print flag.
287
- # * Adjusts the rectangle based on the default font size and the given border width if its
288
- # width and/or height are zero.
289
- def update_widget(appearance_state, border_width)
290
- @widget[:AS] = appearance_state
291
- @widget.flag(:print)
292
-
293
- default_font_size = @document.config['acro_form.default_font_size']
294
- rect = @widget[:Rect]
295
- rect.width = default_font_size + 2 * border_width if rect.width == 0
296
- rect.height = default_font_size + 2 * border_width if rect.height == 0
297
- rect
255
+ # Performs the rotation specified in /R of the appearance characteristics dictionary and
256
+ # returns the correct width, height and Form XObject matrix.
257
+ def perform_rotation(width, height)
258
+ matrix = case (@widget[:MK]&.[](:R) || 0) % 360
259
+ when 90
260
+ width, height = height, width
261
+ [0, 1, -1, 0, 0, 0]
262
+ when 270
263
+ width, height = height, width
264
+ [0, -1, 1, 0, 0, 0]
265
+ when 180
266
+ [0, -1, -1, 0, 0, 0]
267
+ end
268
+ [width, height, matrix]
298
269
  end
299
270
 
300
271
  # Applies the background and border style of the widget annotation to the appearances.
@@ -353,31 +324,32 @@ module HexaPDF
353
324
  # Draws the marker defined by the marker style inside the widget's rectangle.
354
325
  #
355
326
  # This method can only used for check boxes and radio buttons!
356
- def draw_marker(canvas, rect, border_width, marker_style)
327
+ def draw_marker(canvas, width, height, border_width, marker_style)
357
328
  if @field.radio_button? && marker_style.style == :circle
358
329
  # Acrobat handles this specially
359
330
  canvas.
360
331
  fill_color(marker_style.color).
361
- circle(rect.width / 2.0, rect.height / 2.0,
362
- ([rect.width / 2.0, rect.height / 2.0].min - border_width) / 2).
332
+ circle(width / 2.0, height / 2.0,
333
+ ([width / 2.0, height / 2.0].min - border_width) / 2).
363
334
  fill
364
335
  elsif marker_style.style == :cross # Acrobat just places a cross inside
365
336
  canvas.
366
337
  stroke_color(marker_style.color).
367
- line(border_width, border_width, rect.width - border_width,
368
- rect.height - border_width).
369
- line(border_width, rect.height - border_width, rect.width - border_width,
338
+ line(border_width, border_width, width - border_width,
339
+ height - border_width).
340
+ line(border_width, height - border_width, width - border_width,
370
341
  border_width).
371
342
  stroke
372
343
  else
373
344
  font = @document.fonts.add('ZapfDingbats')
374
- mark = font.decode_utf8(@widget[:MK]&.[](:CA) || '4').first
375
- square_width = [rect.width, rect.height].min - 2 * border_width
345
+ marker_string = @widget[:MK]&.[](:CA).to_s
346
+ mark = font.decode_utf8(marker_string.empty? ? '4' : marker_string).first
347
+ square_width = [width, height].min - 2 * border_width
376
348
  font_size = (marker_style.size == 0 ? square_width : marker_style.size)
377
349
  mark_width = mark.width * font.scaling_factor * font_size / 1000.0
378
350
  mark_height = (mark.y_max - mark.y_min) * font.scaling_factor * font_size / 1000.0
379
- x_offset = (rect.width - square_width) / 2.0 + (square_width - mark_width) / 2.0
380
- y_offset = (rect.height - square_width) / 2.0 + (square_width - mark_height) / 2.0 -
351
+ x_offset = (width - square_width) / 2.0 + (square_width - mark_width) / 2.0
352
+ y_offset = (height - square_width) / 2.0 + (square_width - mark_height) / 2.0 -
381
353
  (mark.y_min * font.scaling_factor * font_size / 1000.0)
382
354
 
383
355
  canvas.font(font, size: font_size)
@@ -387,8 +359,9 @@ module HexaPDF
387
359
  end
388
360
 
389
361
  # Draws a single line of text inside the widget's rectangle.
390
- def draw_single_line_text(canvas, rect, style, padding)
391
- value = @field.field_value
362
+ def draw_single_line_text(canvas, width, height, style, padding)
363
+ value, text_color = apply_javascript_formatting(@field.field_value)
364
+ style.fill_color = text_color if text_color
392
365
  fragment = HexaPDF::Layout::TextFragment.create(value, style)
393
366
 
394
367
  if @field.concrete_field_type == :comb_text_field
@@ -396,7 +369,7 @@ module HexaPDF
396
369
  raise HexaPDF::Error, "Missing or invalid dictionary field /MaxLen for comb text field"
397
370
  end
398
371
  new_items = []
399
- cell_width = rect.width.to_f / @field[:MaxLen]
372
+ cell_width = width.to_f / @field[:MaxLen]
400
373
  scaled_cell_width = cell_width / style.scaled_font_size.to_f
401
374
  fragment.items.each_cons(2) do |a, b|
402
375
  new_items << a << -(scaled_cell_width - a.width / 2.0 - b.width / 2.0)
@@ -415,8 +388,8 @@ module HexaPDF
415
388
  # Adobe seems to be left/right-aligning based on twice the border width
416
389
  x = case @field.text_alignment
417
390
  when :left then 2 * padding
418
- when :right then [rect.width - 2 * padding - fragment.width, 2 * padding].max
419
- when :center then [(rect.width - fragment.width) / 2.0, 2 * padding].max
391
+ when :right then [width - 2 * padding - fragment.width, 2 * padding].max
392
+ when :center then [(width - fragment.width) / 2.0, 2 * padding].max
420
393
  end
421
394
  end
422
395
 
@@ -424,13 +397,13 @@ module HexaPDF
424
397
  # available
425
398
  cap_height = style.font.wrapped_font.cap_height * style.font.scaling_factor / 1000.0 *
426
399
  style.font_size
427
- y = padding + (rect.height - 2 * padding - cap_height) / 2.0
400
+ y = padding + (height - 2 * padding - cap_height) / 2.0
428
401
  y = padding - style.scaled_font_descender if y < 0
429
402
  fragment.draw(canvas, x, y)
430
403
  end
431
404
 
432
405
  # Draws multiple lines of text inside the widget's rectangle.
433
- def draw_multiline_text(canvas, rect, style, padding)
406
+ def draw_multiline_text(canvas, width, height, style, padding)
434
407
  items = [Layout::TextFragment.create(@field.field_value, style)]
435
408
  layouter = Layout::TextLayouter.new(style)
436
409
  layouter.style.align(@field.text_alignment).line_spacing(:proportional, 1.25)
@@ -440,22 +413,22 @@ module HexaPDF
440
413
  style.font_size = 12 # Adobe seems to use this as starting point
441
414
  style.clear_cache
442
415
  loop do
443
- result = layouter.fit(items, rect.width - 4 * padding, rect.height - 4 * padding)
416
+ result = layouter.fit(items, width - 4 * padding, height - 4 * padding)
444
417
  break if result.status == :success || style.font_size <= 4 # don't make text too small
445
418
  style.font_size -= 1
446
419
  style.clear_cache
447
420
  end
448
421
  else
449
- result = layouter.fit(items, rect.width - 4 * padding, 2**20)
422
+ result = layouter.fit(items, width - 4 * padding, 2**20)
450
423
  end
451
424
 
452
425
  unless result.lines.empty?
453
- result.draw(canvas, 2 * padding, rect.height - 2 * padding - result.lines[0].height / 2.0)
426
+ result.draw(canvas, 2 * padding, height - 2 * padding - result.lines[0].height / 2.0)
454
427
  end
455
428
  end
456
429
 
457
430
  # Draws the visible option items of the list box in the widget's rectangle.
458
- def draw_list_box(canvas, rect, style, padding)
431
+ def draw_list_box(canvas, width, height, style, padding)
459
432
  option_items = @field.option_items
460
433
  top_index = @field.list_box_top_index
461
434
  items = [Layout::TextFragment.create(option_items[top_index..-1].join("\n"), style)]
@@ -464,18 +437,18 @@ module HexaPDF
464
437
 
465
438
  layouter = Layout::TextLayouter.new(style)
466
439
  layouter.style.align(@field.text_alignment).line_spacing(:proportional, 1.25)
467
- result = layouter.fit(items, rect.width - 4 * padding, rect.height)
440
+ result = layouter.fit(items, width - 4 * padding, height)
468
441
 
469
442
  unless result.lines.empty?
470
443
  top_gap = style.line_spacing.gap(result.lines[0], result.lines[0])
471
444
  line_height = style.line_spacing.baseline_distance(result.lines[0], result.lines[0])
472
445
  canvas.fill_color(153, 193, 218) # Adobe's color for selection highlighting
473
- indices.map! {|i| rect.height - padding - (i - top_index + 1) * line_height }.each do |y|
474
- next if y + line_height > rect.height || y + line_height < padding
475
- canvas.rectangle(padding, y, rect.width - 2 * padding, line_height)
446
+ indices.map! {|i| height - padding - (i - top_index + 1) * line_height }.each do |y|
447
+ next if y + line_height > height || y + line_height < padding
448
+ canvas.rectangle(padding, y, width - 2 * padding, line_height)
476
449
  end
477
450
  canvas.fill if canvas.graphics_object == :path
478
- result.draw(canvas, 2 * padding, rect.height - padding - top_gap)
451
+ result.draw(canvas, 2 * padding, height - padding - top_gap)
479
452
  end
480
453
  end
481
454
 
@@ -501,8 +474,8 @@ module HexaPDF
501
474
  end
502
475
 
503
476
  # Calculates the font size for text fields based on the font and font size of the default
504
- # appearance string, the annotation rectangle and the border style.
505
- def calculate_font_size(font, font_size, rect, border_style)
477
+ # appearance string, the annotation rectangle's height and the border style.
478
+ def calculate_font_size(font, font_size, height, border_style)
506
479
  if font_size == 0
507
480
  case @field.concrete_field_type
508
481
  when :multiline_text_field
@@ -513,13 +486,86 @@ module HexaPDF
513
486
  unit_font_size = (font.wrapped_font.bounding_box[3] - font.wrapped_font.bounding_box[1]) *
514
487
  font.scaling_factor / 1000.0
515
488
  # The constant factor was found empirically by checking what Adobe Reader etc. do
516
- (rect.height - 2 * border_style.width) / unit_font_size * 0.83
489
+ (height - 2 * border_style.width) / unit_font_size * 0.83
517
490
  end
518
491
  else
519
492
  font_size
520
493
  end
521
494
  end
522
495
 
496
+ # Handles Javascript formatting routines for single-line text fields.
497
+ #
498
+ # Returns [value, nil_or_text_color] where value is the new, potentially adjusted field
499
+ # value and the second argument is either +nil+ or the color that should be used for the
500
+ # text value.
501
+ def apply_javascript_formatting(value)
502
+ format_action = @widget[:AA]&.[](:F)
503
+ return [value, nil] unless format_action && format_action[:S] == :JavaScript
504
+ if (match = AF_NUMBER_FORMAT_RE.match(format_action[:JS]))
505
+ apply_af_number_format(value, match)
506
+ else
507
+ [value, nil]
508
+ end
509
+ end
510
+
511
+ # Regular expression for matching the AFNumber_Format Javascript method.
512
+ AF_NUMBER_FORMAT_RE = /
513
+ \AAFNumber_Format\(
514
+ \s*(?<ndec>\d+)\s*,
515
+ \s*(?<sep_style>[0-3])\s*,
516
+ \s*(?<neg_style>[0-3])\s*,
517
+ \s*0\s*,
518
+ \s*(?<currency_string>".*?")\s*,
519
+ \s*(?<prepend>false|true)\s*
520
+ \);\z
521
+ /x
522
+
523
+ # Implements the Javascript AFNumber_Format method.
524
+ #
525
+ # See:
526
+ # - https://experienceleague.adobe.com/docs/experience-manager-learn/assets/FormsAPIReference.pdf
527
+ # - https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsapiref/JS_API_AcroJS.html#printf
528
+ def apply_af_number_format(value, match)
529
+ value = value.to_f
530
+ format = "%.#{match[:ndec]}f"
531
+ text_color = 'black'
532
+
533
+ currency_string = JSON.parse(match[:currency_string])
534
+ format = (match[:prepend] == 'true' ? currency_string + format : format + currency_string)
535
+
536
+ if value < 0
537
+ value = value.abs
538
+ case match[:neg_style]
539
+ when '0' # MinusBlack
540
+ format = "-#{format}"
541
+ when '1' # Red
542
+ text_color = 'red'
543
+ when '2' # ParensBlack
544
+ format = "(#{format})"
545
+ when '3' # ParensRed
546
+ format = "(#{format})"
547
+ text_color = 'red'
548
+ end
549
+ end
550
+
551
+ result = sprintf(format, value)
552
+
553
+ # sep_style: 0=12,345.67, 1=12345.67, 2=12.345,67, 3=12345,67
554
+ before_decimal_point, after_decimal_point = result.split('.')
555
+ if match[:sep_style] == '0' || match[:sep_style] == '2'
556
+ separator = (match[:sep_style] == '0' ? ',' : '.')
557
+ before_decimal_point.gsub!(/\B(?=(\d\d\d)+(?:[^\d]|\z))/, separator)
558
+ end
559
+ result = if after_decimal_point
560
+ decimal_point = (match[:sep_style] =~ /[01]/ ? '.' : ',')
561
+ "#{before_decimal_point}#{decimal_point}#{after_decimal_point}"
562
+ else
563
+ before_decimal_point
564
+ end
565
+
566
+ [result, text_color]
567
+ end
568
+
523
569
  end
524
570
 
525
571
  end
@@ -84,6 +84,8 @@ module HexaPDF
84
84
  # See: PDF1.7 s12.7.4.2
85
85
  class ButtonField < Field
86
86
 
87
+ define_type :XXAcroFormField
88
+
87
89
  define_field :Opt, type: PDFArray, version: '1.4'
88
90
 
89
91
  # All inheritable dictionary fields for button fields.
@@ -206,7 +208,7 @@ module HexaPDF
206
208
  # Note that this will only return useful values if there is at least one correctly set-up
207
209
  # widget.
208
210
  def allowed_values
209
- (each_widget.each_with_object([]) do |widget, result|
211
+ (each_widget.with_object([]) do |widget, result|
210
212
  keys = widget.appearance_dict&.normal_appearance&.value&.keys
211
213
  result.concat(keys) if keys
212
214
  end - [:Off]).uniq
@@ -254,14 +256,14 @@ module HexaPDF
254
256
  normal_appearance = widget.appearance_dict&.normal_appearance
255
257
  next if !force && normal_appearance &&
256
258
  ((!push_button? && normal_appearance.value.length == 2 &&
257
- normal_appearance.value.each_value.all?(HexaPDF::Stream)) ||
259
+ normal_appearance.each.all? {|_, v| v.kind_of?(HexaPDF::Stream) }) ||
258
260
  (push_button? && normal_appearance.kind_of?(HexaPDF::Stream)))
259
261
  if check_box?
260
262
  appearance_generator_class.new(widget).create_check_box_appearances
261
263
  elsif radio_button?
262
264
  appearance_generator_class.new(widget).create_radio_button_appearances
263
265
  else
264
- raise HexaPDF::Error, "Push buttons not yet supported"
266
+ appearance_generator_class.new(widget).create_push_button_appearances
265
267
  end
266
268
  end
267
269
  end
@@ -69,6 +69,8 @@ module HexaPDF
69
69
  # See: PDF1.7 s12.7.4.4
70
70
  class ChoiceField < VariableTextField
71
71
 
72
+ define_type :XXAcroFormField
73
+
72
74
  define_field :Opt, type: PDFArray
73
75
  define_field :TI, type: Integer, default: 0
74
76
  define_field :I, type: PDFArray, version: '1.4'
@@ -242,8 +242,8 @@ module HexaPDF
242
242
  end
243
243
 
244
244
  # :call-seq:
245
- # field.each_widget {|widget| block} -> field
246
- # field.each_widget -> Enumerator
245
+ # field.each_widget(direct_only: true) {|widget| block} -> field
246
+ # field.each_widget(direct_only: true) -> Enumerator
247
247
  #
248
248
  # Yields each widget, i.e. visual representation, of this field.
249
249
  #
@@ -253,11 +253,17 @@ module HexaPDF
253
253
  # 2. One or more widgets are defined as children of this field.
254
254
  # 3. Widgets of *another field instance with the same full field name*.
255
255
  #
256
- # Because of possibility 3 all fields of the form have to be searched to check whether there
257
- # is another field with the same full field name.
256
+ # With the default of +direct_only+ being +true+, only the usual cases 1 and 2 are handled/
257
+ # If case 3 also needs to be handled, set +direct_only+ to +false+ or run the validation on
258
+ # the main AcroForm object (HexaPDF::Document#acro_form) before using this method (this will
259
+ # reduce case 3 to case 2).
260
+ #
261
+ # *Note*: Setting +direct_only+ to +false+ will have a severe performance impact since all
262
+ # fields of the form have to be searched to check whether there is another field with the
263
+ # same full field name.
258
264
  #
259
265
  # See: HexaPDF::Type::Annotations::Widget
260
- def each_widget(direct_only: false, &block) # :yields: widget
266
+ def each_widget(direct_only: true, &block) # :yields: widget
261
267
  return to_enum(__method__, direct_only: direct_only) unless block_given?
262
268
 
263
269
  if embedded_widget?
@@ -125,14 +125,22 @@ module HexaPDF
125
125
  def each_field(terminal_only: true)
126
126
  return to_enum(__method__, terminal_only: terminal_only) unless block_given?
127
127
 
128
- process_field = lambda do |field|
129
- field = document.wrap(field, type: :XXAcroFormField,
130
- subtype: Field.inherited_value(field, :FT))
131
- yield(field) if field.terminal_field? || !terminal_only
132
- field[:Kids].each(&process_field) unless field.terminal_field?
128
+ process_field_array = lambda do |array|
129
+ array.each_with_index do |field, index|
130
+ unless field.respond_to?(:type) && field.type == :XXAcroFormField
131
+ array[index] = field = document.wrap(field, type: :XXAcroFormField,
132
+ subtype: Field.inherited_value(field, :FT))
133
+ end
134
+ if field.terminal_field?
135
+ yield(field)
136
+ else
137
+ yield(field) unless terminal_only
138
+ process_field_array.call(field[:Kids])
139
+ end
140
+ end
133
141
  end
134
142
 
135
- root_fields.each(&process_field)
143
+ process_field_array.call(root_fields)
136
144
  self
137
145
  end
138
146
 
@@ -393,7 +401,7 @@ module HexaPDF
393
401
  fields.each {|field| field.create_appearances if field.respond_to?(:create_appearances) }
394
402
  end
395
403
 
396
- not_flattened = fields.map {|field| field.each_widget.to_a }.flatten
404
+ not_flattened = fields.map {|field| field.each_widget(direct_only: true).to_a }.flatten
397
405
  document.pages.each {|page| not_flattened = page.flatten_annotations(not_flattened) }
398
406
  not_flattened.map!(&:form_field)
399
407
  fields -= not_flattened
@@ -449,6 +457,8 @@ module HexaPDF
449
457
  def perform_validation # :nodoc:
450
458
  super
451
459
 
460
+ seen = {} # used for combining field
461
+
452
462
  validate_array = lambda do |parent, container|
453
463
  container.reject! do |field|
454
464
  if !field.kind_of?(HexaPDF::Object) || !field.kind_of?(HexaPDF::Dictionary) || field.null?
@@ -469,6 +479,22 @@ module HexaPDF
469
479
  field[:Parent] = parent
470
480
  end
471
481
  end
482
+
483
+ # Combine fields with same name
484
+ name = field.full_field_name
485
+ if (other_field = seen[name])
486
+ kids = other_field[:Kids] ||= []
487
+ kids << other_field.send(:extract_widget) if other_field.embedded_widget?
488
+ widgets = field.embedded_widget? ? [field.send(:extract_widget)] : field.each_widget.to_a
489
+ widgets.each do |widget|
490
+ widget[:Parent] = other_field
491
+ kids << widget
492
+ end
493
+ reject = true
494
+ elsif !reject
495
+ seen[name] = field
496
+ end
497
+
472
498
  validate_array.call(field, field[:Kids]) if field.key?(:Kids)
473
499
  reject
474
500
  end
@@ -192,6 +192,8 @@ module HexaPDF
192
192
 
193
193
  end
194
194
 
195
+ define_type :XXAcroFormField
196
+
195
197
  define_field :Lock, type: :SigFieldLock, indirect: true, version: '1.5'
196
198
  define_field :SV, type: :SV, indirect: true, version: '1.5'
197
199