hexapdf 0.26.2 → 0.28.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +115 -1
  3. data/README.md +1 -1
  4. data/examples/013-text_layouter_shapes.rb +8 -8
  5. data/examples/016-frame_automatic_box_placement.rb +3 -3
  6. data/examples/017-frame_text_flow.rb +3 -3
  7. data/examples/019-acro_form.rb +14 -3
  8. data/examples/020-column_box.rb +3 -3
  9. data/examples/023-images.rb +30 -0
  10. data/lib/hexapdf/cli/info.rb +5 -1
  11. data/lib/hexapdf/cli/inspect.rb +2 -2
  12. data/lib/hexapdf/cli/split.rb +8 -8
  13. data/lib/hexapdf/cli/watermark.rb +2 -2
  14. data/lib/hexapdf/configuration.rb +3 -2
  15. data/lib/hexapdf/content/canvas.rb +8 -3
  16. data/lib/hexapdf/dictionary.rb +4 -17
  17. data/lib/hexapdf/document/destinations.rb +42 -5
  18. data/lib/hexapdf/document/signatures.rb +265 -48
  19. data/lib/hexapdf/document.rb +6 -10
  20. data/lib/hexapdf/filter/ascii85_decode.rb +1 -1
  21. data/lib/hexapdf/importer.rb +35 -27
  22. data/lib/hexapdf/layout/list_box.rb +1 -5
  23. data/lib/hexapdf/object.rb +5 -0
  24. data/lib/hexapdf/parser.rb +14 -0
  25. data/lib/hexapdf/revision.rb +15 -12
  26. data/lib/hexapdf/revisions.rb +7 -1
  27. data/lib/hexapdf/tokenizer.rb +15 -9
  28. data/lib/hexapdf/type/acro_form/appearance_generator.rb +174 -128
  29. data/lib/hexapdf/type/acro_form/button_field.rb +5 -3
  30. data/lib/hexapdf/type/acro_form/choice_field.rb +2 -0
  31. data/lib/hexapdf/type/acro_form/field.rb +11 -5
  32. data/lib/hexapdf/type/acro_form/form.rb +61 -8
  33. data/lib/hexapdf/type/acro_form/signature_field.rb +2 -0
  34. data/lib/hexapdf/type/acro_form/text_field.rb +12 -2
  35. data/lib/hexapdf/type/annotations/widget.rb +3 -0
  36. data/lib/hexapdf/type/catalog.rb +1 -1
  37. data/lib/hexapdf/type/font_true_type.rb +14 -0
  38. data/lib/hexapdf/type/object_stream.rb +2 -2
  39. data/lib/hexapdf/type/outline.rb +19 -1
  40. data/lib/hexapdf/type/outline_item.rb +72 -14
  41. data/lib/hexapdf/type/page.rb +95 -64
  42. data/lib/hexapdf/type/resources.rb +13 -17
  43. data/lib/hexapdf/type/signature/adbe_pkcs7_detached.rb +16 -2
  44. data/lib/hexapdf/type/signature.rb +10 -0
  45. data/lib/hexapdf/version.rb +1 -1
  46. data/lib/hexapdf/writer.rb +5 -3
  47. data/test/hexapdf/content/test_canvas.rb +5 -0
  48. data/test/hexapdf/document/test_destinations.rb +41 -0
  49. data/test/hexapdf/document/test_pages.rb +2 -2
  50. data/test/hexapdf/document/test_signatures.rb +139 -19
  51. data/test/hexapdf/encryption/test_aes.rb +1 -1
  52. data/test/hexapdf/filter/test_predictor.rb +0 -1
  53. data/test/hexapdf/layout/test_box.rb +2 -1
  54. data/test/hexapdf/layout/test_column_box.rb +1 -1
  55. data/test/hexapdf/layout/test_list_box.rb +1 -1
  56. data/test/hexapdf/test_document.rb +2 -8
  57. data/test/hexapdf/test_importer.rb +27 -6
  58. data/test/hexapdf/test_parser.rb +19 -2
  59. data/test/hexapdf/test_revision.rb +15 -14
  60. data/test/hexapdf/test_revisions.rb +63 -12
  61. data/test/hexapdf/test_stream.rb +1 -1
  62. data/test/hexapdf/test_tokenizer.rb +10 -1
  63. data/test/hexapdf/test_writer.rb +11 -3
  64. data/test/hexapdf/type/acro_form/test_appearance_generator.rb +135 -56
  65. data/test/hexapdf/type/acro_form/test_button_field.rb +6 -1
  66. data/test/hexapdf/type/acro_form/test_choice_field.rb +4 -0
  67. data/test/hexapdf/type/acro_form/test_field.rb +4 -4
  68. data/test/hexapdf/type/acro_form/test_form.rb +65 -0
  69. data/test/hexapdf/type/acro_form/test_signature_field.rb +4 -0
  70. data/test/hexapdf/type/acro_form/test_text_field.rb +13 -0
  71. data/test/hexapdf/type/signature/common.rb +54 -0
  72. data/test/hexapdf/type/signature/test_adbe_pkcs7_detached.rb +21 -0
  73. data/test/hexapdf/type/test_catalog.rb +5 -2
  74. data/test/hexapdf/type/test_font_true_type.rb +20 -0
  75. data/test/hexapdf/type/test_object_stream.rb +2 -1
  76. data/test/hexapdf/type/test_outline.rb +4 -1
  77. data/test/hexapdf/type/test_outline_item.rb +62 -1
  78. data/test/hexapdf/type/test_page.rb +103 -45
  79. data/test/hexapdf/type/test_page_tree_node.rb +4 -2
  80. data/test/hexapdf/type/test_resources.rb +0 -5
  81. data/test/hexapdf/type/test_signature.rb +8 -0
  82. data/test/test_helper.rb +1 -1
  83. metadata +61 -4
@@ -229,16 +229,22 @@ module HexaPDF
229
229
  end
230
230
 
231
231
  # :call-seq:
232
- # revision.each_modified_object {|obj| block } -> revision
233
- # revision.each_modified_object -> Enumerator
232
+ # revision.each_modified_object(delete: false, all: all) {|obj| block } -> revision
233
+ # revision.each_modified_object(delete: false, all: all) -> Enumerator
234
234
  #
235
- # Calls the given block once for each object that has been modified since it was loaded. Deleted
236
- # object and cross-reference streams are ignored.
235
+ # Calls the given block once for each object that has been modified since it was loaded. Added
236
+ # or eleted object and cross-reference streams as well as signature dictionaries are ignored.
237
+ #
238
+ # +delete+:: If the +delete+ argument is set to +true+, each modified object is deleted from the
239
+ # active objects.
240
+ #
241
+ # +all+:: If the +all+ argument is set to +true+, added object and cross-reference streams are
242
+ # also yielded.
237
243
  #
238
244
  # Note that this also means that for revisions without an associated cross-reference section all
239
245
  # loaded objects will be yielded.
240
- def each_modified_object
241
- return to_enum(__method__) unless block_given?
246
+ def each_modified_object(delete: false, all: false)
247
+ return to_enum(__method__, delete: delete, all: all) unless block_given?
242
248
 
243
249
  @objects.each do |oid, gen, obj|
244
250
  if @xref_section.entry?(oid, gen)
@@ -259,20 +265,17 @@ module HexaPDF
259
265
  end
260
266
  next if values_unchanged && streams_are_same
261
267
  end
268
+ elsif !all && (obj.type == :XRef || obj.type == :ObjStm)
269
+ next
262
270
  end
263
271
 
264
272
  yield(obj)
273
+ @objects.delete(oid) if delete
265
274
  end
266
275
 
267
276
  self
268
277
  end
269
278
 
270
- # Resets the revision by deleting all loaded and added objects from it.
271
- def reset_objects
272
- @objects = HexaPDF::Utils::ObjectHash.new
273
- @all_objects_loaded = false
274
- end
275
-
276
279
  private
277
280
 
278
281
  # Loads a single object from the associated cross-reference section.
@@ -93,15 +93,21 @@ module HexaPDF
93
93
  seen_xref_offsets[stm] = true
94
94
  end
95
95
 
96
+ if parser.linearized? && !trailer.key?(:Prev)
97
+ merge_revision = offset
98
+ end
99
+
96
100
  if merge_revision == offset
97
101
  xref_section.merge!(revisions.first.xref_section)
102
+ offset = trailer[:Prev] # Get possible next offset before overwriting trailer
98
103
  trailer = revisions.first.trailer
99
104
  revisions.shift
105
+ else
106
+ offset = trailer[:Prev]
100
107
  end
101
108
 
102
109
  revisions.unshift(Revision.new(document.wrap(trailer, type: :XXTrailer),
103
110
  xref_section: xref_section, loader: object_loader))
104
- offset = trailer[:Prev]
105
111
  end
106
112
  rescue HexaPDF::MalformedPDFError
107
113
  raise unless (reconstructed_revision = parser.reconstructed_revision)
@@ -274,7 +274,7 @@ module HexaPDF
274
274
  TOKEN_CACHE[str]
275
275
  end
276
276
 
277
- REFERENCE_RE = /[#{WHITESPACE}]+([+-]?\d+)[#{WHITESPACE}]+R#{WHITESPACE_OR_DELIMITER_RE}/ # :nodoc:
277
+ REFERENCE_RE = /[#{WHITESPACE}]+([+]?\d+)[#{WHITESPACE}]+R#{WHITESPACE_OR_DELIMITER_RE}/ # :nodoc:
278
278
 
279
279
  # Parses the number (integer or real) at the current position.
280
280
  #
@@ -285,7 +285,14 @@ module HexaPDF
285
285
  tmp = val.to_i
286
286
  # Handle object references, see PDF1.7 s7.3.10
287
287
  prepare_string_scanner(10)
288
- tmp = Reference.new(tmp, @ss[1].to_i) if @ss.scan(REFERENCE_RE)
288
+ if @ss.scan(REFERENCE_RE)
289
+ tmp = if tmp > 0
290
+ Reference.new(tmp, @ss[1].to_i)
291
+ else
292
+ maybe_raise("Invalid indirect object reference (#{tmp},#{@ss[1].to_i})")
293
+ nil
294
+ end
295
+ end
289
296
  tmp
290
297
  elsif val.match?(/\A[+-]?(?:\d+\.\d*|\.\d+)\z/)
291
298
  val << '0' if val.getbyte(-1) == 46 # dot '.'
@@ -315,21 +322,20 @@ module HexaPDF
315
322
  parentheses = 1
316
323
 
317
324
  while parentheses != 0
318
- data = scan_until(/([()\\\r])/)
319
- char = @ss[1]
325
+ data = scan_until(/[()\\\r]/)
320
326
  unless data
321
327
  raise HexaPDF::MalformedPDFError.new("Unclosed literal string found", pos: pos)
322
328
  end
323
329
 
324
330
  str << data
325
331
  prepare_string_scanner if @ss.eos?
326
- case char
327
- when '(' then parentheses += 1
328
- when ')' then parentheses -= 1
329
- when "\r"
332
+ case @ss.string.getbyte(@ss.pos - 1)
333
+ when 41 then parentheses -= 1 # )
334
+ when 40 then parentheses += 1 # (
335
+ when 13 # \r
330
336
  str[-1] = "\n"
331
337
  @ss.pos += 1 if @ss.peek(1) == "\n"
332
- when '\\'
338
+ when 92 # \\
333
339
  str.chop!
334
340
  byte = @ss.get_byte
335
341
  if (data = LITERAL_STRING_ESCAPE_MAP[byte])
@@ -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