hexapdf 0.27.0 → 0.28.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 (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