hexapdf 0.26.2 → 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 (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