hexapdf 0.40.0 → 0.42.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/examples/019-acro_form.rb +12 -23
  4. data/examples/027-composer_optional_content.rb +1 -1
  5. data/examples/030-pdfa.rb +6 -6
  6. data/examples/031-acro_form_java_script.rb +113 -0
  7. data/lib/hexapdf/cli/command.rb +25 -11
  8. data/lib/hexapdf/cli/files.rb +31 -7
  9. data/lib/hexapdf/cli/form.rb +46 -38
  10. data/lib/hexapdf/cli/info.rb +4 -0
  11. data/lib/hexapdf/cli/inspect.rb +1 -1
  12. data/lib/hexapdf/cli/usage.rb +215 -0
  13. data/lib/hexapdf/cli.rb +2 -0
  14. data/lib/hexapdf/configuration.rb +11 -1
  15. data/lib/hexapdf/content/canvas.rb +2 -0
  16. data/lib/hexapdf/document/layout.rb +8 -1
  17. data/lib/hexapdf/encryption/aes.rb +13 -6
  18. data/lib/hexapdf/encryption/security_handler.rb +6 -4
  19. data/lib/hexapdf/font/cmap/parser.rb +1 -5
  20. data/lib/hexapdf/font/cmap.rb +22 -3
  21. data/lib/hexapdf/font_loader/from_configuration.rb +1 -1
  22. data/lib/hexapdf/font_loader/variant_from_name.rb +72 -0
  23. data/lib/hexapdf/font_loader.rb +1 -0
  24. data/lib/hexapdf/layout/style.rb +5 -4
  25. data/lib/hexapdf/type/acro_form/appearance_generator.rb +11 -76
  26. data/lib/hexapdf/type/acro_form/button_field.rb +7 -5
  27. data/lib/hexapdf/type/acro_form/field.rb +14 -0
  28. data/lib/hexapdf/type/acro_form/form.rb +70 -8
  29. data/lib/hexapdf/type/acro_form/java_script_actions.rb +649 -0
  30. data/lib/hexapdf/type/acro_form/text_field.rb +90 -0
  31. data/lib/hexapdf/type/acro_form.rb +1 -0
  32. data/lib/hexapdf/type/annotations/widget.rb +1 -1
  33. data/lib/hexapdf/type/resources.rb +2 -1
  34. data/lib/hexapdf/utils.rb +19 -0
  35. data/lib/hexapdf/version.rb +1 -1
  36. data/test/hexapdf/encryption/test_aes.rb +18 -8
  37. data/test/hexapdf/encryption/test_security_handler.rb +17 -0
  38. data/test/hexapdf/encryption/test_standard_security_handler.rb +1 -1
  39. data/test/hexapdf/font/cmap/test_parser.rb +5 -3
  40. data/test/hexapdf/font/test_cmap.rb +8 -0
  41. data/test/hexapdf/font_loader/test_from_configuration.rb +4 -0
  42. data/test/hexapdf/font_loader/test_variant_from_name.rb +34 -0
  43. data/test/hexapdf/test_utils.rb +16 -0
  44. data/test/hexapdf/type/acro_form/test_appearance_generator.rb +31 -47
  45. data/test/hexapdf/type/acro_form/test_button_field.rb +5 -0
  46. data/test/hexapdf/type/acro_form/test_field.rb +11 -0
  47. data/test/hexapdf/type/acro_form/test_form.rb +80 -0
  48. data/test/hexapdf/type/acro_form/test_java_script_actions.rb +327 -0
  49. data/test/hexapdf/type/acro_form/test_text_field.rb +62 -0
  50. data/test/hexapdf/type/test_resources.rb +5 -0
  51. metadata +8 -2
@@ -0,0 +1,649 @@
1
+ # -*- encoding: utf-8; frozen_string_literal: true -*-
2
+ #
3
+ #--
4
+ # This file is part of HexaPDF.
5
+ #
6
+ # HexaPDF - A Versatile PDF Creation and Manipulation Library For Ruby
7
+ # Copyright (C) 2014-2024 Thomas Leitner
8
+ #
9
+ # HexaPDF is free software: you can redistribute it and/or modify it
10
+ # under the terms of the GNU Affero General Public License version 3 as
11
+ # published by the Free Software Foundation with the addition of the
12
+ # following permission added to Section 15 as permitted in Section 7(a):
13
+ # FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY
14
+ # THOMAS LEITNER, THOMAS LEITNER DISCLAIMS THE WARRANTY OF NON
15
+ # INFRINGEMENT OF THIRD PARTY RIGHTS.
16
+ #
17
+ # HexaPDF is distributed in the hope that it will be useful, but WITHOUT
18
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
19
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
20
+ # License for more details.
21
+ #
22
+ # You should have received a copy of the GNU Affero General Public License
23
+ # along with HexaPDF. If not, see <http://www.gnu.org/licenses/>.
24
+ #
25
+ # The interactive user interfaces in modified source and object code
26
+ # versions of HexaPDF must display Appropriate Legal Notices, as required
27
+ # under Section 5 of the GNU Affero General Public License version 3.
28
+ #
29
+ # In accordance with Section 7(b) of the GNU Affero General Public
30
+ # License, a covered work must retain the producer line in every PDF that
31
+ # is created or manipulated using HexaPDF.
32
+ #
33
+ # If the GNU Affero General Public License doesn't fit your need,
34
+ # commercial licenses are available at <https://gettalong.at/hexapdf/>.
35
+ #++
36
+
37
+ require 'json'
38
+ require 'time'
39
+ require 'hexapdf/error'
40
+ require 'hexapdf/layout/style'
41
+ require 'hexapdf/layout/text_fragment'
42
+ require 'hexapdf/layout/text_layouter'
43
+
44
+ module HexaPDF
45
+ module Type
46
+ module AcroForm
47
+
48
+ # The JavaScriptActions module implements JavaScript actions that can be specified for form
49
+ # fields, such as formatting or calculating a field's value.
50
+ #
51
+ # These JavaScript functions are not specified in the PDF specification but can be found in
52
+ # other reference materials (e.g. from Adobe).
53
+ #
54
+ # Formatting a field's value::
55
+ #
56
+ # The main entry point is #apply_format which applies the format to a value. Supported
57
+ # JavaScript actions are:
58
+ #
59
+ # * +AFNumber_Format+: See #af_number_format_action and #apply_af_number_format
60
+ # * +AFPercent_Format+: See #af_percent_format_action and #apply_af_percent_format
61
+ #
62
+ # Calculating a field's value::
63
+ #
64
+ # The main entry point is #calculate which calculates a field's value. Supported JavaScript
65
+ # actions are:
66
+ #
67
+ # * +AFSimple_Calculate+: See #af_simple_calculate_action and #run_af_simple_calculate
68
+ # * Simplified Field Notation expressions: See #simplified_field_notation_action and
69
+ # #run_simplified_field_notation
70
+ #
71
+ # See: PDF2.0 s12.6.4.17
72
+ #
73
+ # See:
74
+ # - https://experienceleague.adobe.com/docs/experience-manager-learn/assets/FormsAPIReference.pdf
75
+ # - https://opensource.adobe.com/dc-acrobat-sdk-docs/library/jsapiref/JS_API_AcroJS.html#printf
76
+ module JavaScriptActions
77
+
78
+ # Implements a parser for the simplified field notation used for calculating field values.
79
+ #
80
+ # This notation is used if the predefined functions are too simple but the calculation can
81
+ # still be done by simple arithmetic.
82
+ class SimplifiedFieldNotationParser
83
+
84
+ # Raised if there was an error during parsing.
85
+ class ParseError < StandardError; end
86
+
87
+ # Creates a new instance for the given AcroForm +form+ instance and simplified field
88
+ # notation string +sfn_string+.
89
+ def initialize(form, sfn_string)
90
+ @form = form
91
+ @tokens = sfn_string.scan(/\p{Alpha}[^()*\/+-]*|\d+(?:[.,]\d*)?|[()*\/+-]/)
92
+ end
93
+
94
+ # Parses the string holding the simplified field notation.
95
+ #
96
+ # If +operations+ is :calculate, the calculation is performed and the result returned. If
97
+ # +operations+ is :generate, a JavaScript representation is generated and returned.
98
+ #
99
+ # +nil+ is returned regardless of the +operations+ value if there was any problem.
100
+ def parse(operations = :calculate)
101
+ operations = (operations == :calculate ? CALCULATE_OPERATIONS : JS_GENERATE_OPERATIONS)
102
+ result = expression(operations)
103
+ @tokens.empty? ? result : nil
104
+ rescue ParseError
105
+ nil
106
+ end
107
+
108
+ private
109
+
110
+ # Implementation of the operations for calculating the result.
111
+ CALCULATE_OPERATIONS = {
112
+ '+' => lambda {|l, r| l + r },
113
+ '-' => lambda {|l, r| l - r },
114
+ '*' => lambda {|l, r| l * r },
115
+ '/' => lambda {|l, r| l / r },
116
+ field: lambda {|field| JavaScriptActions.af_make_number(field.field_value) },
117
+ number: lambda {|token| JavaScriptActions.af_make_number(token) },
118
+ parens: lambda {|expr| expr },
119
+ }
120
+
121
+ # Implementation of the operations for generating the equivalent JavaScript code.
122
+ JS_GENERATE_OPERATIONS = {
123
+ '+' => lambda {|l, r| "#{l} + #{r}" },
124
+ '-' => lambda {|l, r| "#{l} - #{r}" },
125
+ '*' => lambda {|l, r| "#{l} * #{r}" },
126
+ '/' => lambda {|l, r| "#{l} / #{r}" },
127
+ field: lambda {|field| "AFMakeNumber(getField(#{field.full_field_name.to_json}).value)" },
128
+ number: lambda {|token| JavaScriptActions.af_make_number(token).to_s },
129
+ parens: lambda {|expr| "(#{expr})" },
130
+ }
131
+
132
+ # Parses the expression at the current position.
133
+ #
134
+ # expression = term [('+'|'-') term]*
135
+ def expression(operations)
136
+ result = term(operations)
137
+ while @tokens.first == '+' || @tokens.first == '-'
138
+ result = operations[@tokens.shift].call(result, term(operations))
139
+ end
140
+ result
141
+ end
142
+
143
+ # Parses the term at the current position.
144
+ #
145
+ # term = factor [('*'|'/') factor]*
146
+ def term(operations)
147
+ result = factor(operations)
148
+ while @tokens.first == '*' || @tokens.first == '/'
149
+ result = operations[@tokens.shift].call(result, factor(operations))
150
+ end
151
+ result
152
+ end
153
+
154
+ # Parses the factor at the current position.
155
+ #
156
+ # factor = '(' expr ')' | field_name | number
157
+ def factor(operations)
158
+ token = @tokens.shift
159
+ if token == '('
160
+ value = expression(operations)
161
+ raise ParseError, "Unmatched parentheses" unless @tokens.shift == ')'
162
+ operations[:parens].call(value)
163
+ elsif (field = @form.field_by_name(token.strip.gsub('\\', ''))) && field.terminal_field?
164
+ operations[:field].call(field)
165
+ elsif token.match?(/\A\d+(?:[.,]\d*)?\z/)
166
+ operations[:number].call(token)
167
+ else
168
+ raise ParseError, "Invalid token encountered: #{token}"
169
+ end
170
+ end
171
+
172
+ end
173
+
174
+ module_function
175
+
176
+ # Handles JavaScript field format actions for single-line text fields.
177
+ #
178
+ # The argument +value+ is the value that should be formatted and +format_action+ is the PDF
179
+ # format action object that should be applied. The latter may be +nil+ if no associated
180
+ # format action is available.
181
+ #
182
+ # Returns [value, nil_or_text_color] where value is the new, potentially changed field value
183
+ # and the second argument is either +nil+ (no change in color) or the color that should be
184
+ # used for the text value.
185
+ def apply_format(value, format_action)
186
+ return [value, nil] unless (action_string = action_string(format_action))
187
+ if action_string.start_with?('AFNumber_Format(')
188
+ apply_af_number_format(value, action_string)
189
+ elsif action_string.start_with?('AFPercent_Format(')
190
+ apply_af_percent_format(value, action_string)
191
+ elsif action_string.start_with?('AFTime_Format(')
192
+ apply_af_time_format(value, action_string)
193
+ else
194
+ [value, nil]
195
+ end
196
+ end
197
+
198
+ AF_NUMBER_FORMAT_MAPPINGS = { #:nodoc:
199
+ separator: {
200
+ point: 0,
201
+ point_no_thousands: 1,
202
+ comma: 2,
203
+ comma_no_thousands: 3,
204
+ },
205
+ negative: {
206
+ minus_black: 0,
207
+ red: 1,
208
+ parens_black: 2,
209
+ parens_red: 3,
210
+ },
211
+ }
212
+
213
+ # Returns the appropriate JavaScript action string for the AFNumber_Format function.
214
+ #
215
+ # +decimals+::
216
+ # The number of decimal digits to use. Default 2.
217
+ #
218
+ # +separator_style+::
219
+ # Specifies the character for the decimal and thousands separator, one of:
220
+ #
221
+ # :point:: (Default) Use point as decimal separator and comma as thousands separator.
222
+ # :point_no_thousands:: Use point as decimal separator and no thousands separator.
223
+ # :comma:: Use comma as decimal separator and point as thousands separator.
224
+ # :comma_no_thousands:: Use comma as decimal separator and no thousands separator.
225
+ #
226
+ # +negative_style+::
227
+ # Specifies how negative numbers should be formatted, one of:
228
+ #
229
+ # :minus_black:: (Default) Use minus before the number and black as color.
230
+ # :red:: Just use red as color.
231
+ # :parens_black:: Use parentheses around the number and black as color.
232
+ # :parens_red:: Use parentheses around the number and red as color.
233
+ #
234
+ # +currency_string+::
235
+ # Specifies the currency string that should be used. Default is the empty string.
236
+ #
237
+ # +prepend_currency+::
238
+ # Specifies whether the currency string should be prepended (+true+, default) or
239
+ # appended (+false).
240
+ #
241
+ # See: #apply_af_number_format
242
+ def af_number_format_action(decimals: 2, separator_style: :point, negative_style: :minus_black,
243
+ currency_string: "", prepend_currency: true)
244
+ separator_style = AF_NUMBER_FORMAT_MAPPINGS[:separator].fetch(separator_style) do
245
+ raise ArgumentError, "Unsupported value for separator_style argument: #{separator_style}"
246
+ end
247
+ negative_style = AF_NUMBER_FORMAT_MAPPINGS[:negative].fetch(negative_style) do
248
+ raise ArgumentError, "Unsupported value for negative_style argument: #{negative_style}"
249
+ end
250
+
251
+ "AFNumber_Format(#{decimals}, #{separator_style}, " \
252
+ "#{negative_style}, 0, \"#{currency_string}\", " \
253
+ "#{prepend_currency});"
254
+ end
255
+
256
+ # Regular expression for matching the AFNumber_Format method.
257
+ #
258
+ # See: #apply_af_number_format
259
+ AF_NUMBER_FORMAT_RE = /
260
+ \AAFNumber_Format\(
261
+ \s*(?<ndec>\d+)\s*,
262
+ \s*(?<sep_style>[0-3])\s*,
263
+ \s*(?<neg_style>[0-3])\s*,
264
+ \s*0\s*,
265
+ \s*(?<currency_string>".*?")\s*,
266
+ \s*(?<prepend>false|true)\s*
267
+ \);?\z
268
+ /x
269
+
270
+ # Implements the JavaScript AFNumber_Format function and returns the formatted field value.
271
+ #
272
+ # The argument +value+ has to be the field's value (a String) and +action_string+ has to be
273
+ # the JavaScript action string.
274
+ #
275
+ # The AFNumber_Format function assumes that the text field's value contains a number (as a
276
+ # string) and formats it according to the instructions.
277
+ #
278
+ # It has the form <tt>AFNumber_Format(no_of_decimals, separator_style, negative_style,
279
+ # currency_style, currency_string, prepend_currency)</tt> where the arguments have the
280
+ # following meaning:
281
+ #
282
+ # +no_of_decimals+::
283
+ # The number of decimal places after the decimal point, e.g. for 3 it would result in
284
+ # 123.456.
285
+ #
286
+ # +separator_style+::
287
+ # Defines which decimal separator and whether a thousands separator should be used.
288
+ #
289
+ # Possible values are:
290
+ #
291
+ # +0+:: Comma for thousands separator, point for decimal separator: 12,345.67
292
+ # +1+:: No thousands separator, point for decimal separator: 12345.67
293
+ # +2+:: Point for thousands separator, comma for decimal separator: 12.345,67
294
+ # +3+:: No thousands separator, comma for decimal separator: 12345,67
295
+ #
296
+ # +negative_style+::
297
+ # Defines how negative numbers should be formatted.
298
+ #
299
+ # Possible values are:
300
+ #
301
+ # +0+:: With minus and in color black: -12,345.67
302
+ # +1+:: Just in color red: 12,345.67
303
+ # +2+:: With parentheses and in color black: (12,345.67)
304
+ # +3+:: With parentheses and in color red: (12,345.67)
305
+ #
306
+ # +currency_style+::
307
+ # This argument is not used, should be 0.
308
+ #
309
+ # +currency_string+::
310
+ # A string with the currency symbol, e.g. € or $.
311
+ #
312
+ # +prepend_currency+::
313
+ # A boolean defining whether the currency string should be prepended (+true+) or appended
314
+ # (+false+).
315
+ def apply_af_number_format(value, action_string)
316
+ return [value, nil] unless (match = AF_NUMBER_FORMAT_RE.match(action_string))
317
+ value = af_make_number(value)
318
+ format = "%.#{match[:ndec]}f"
319
+ text_color = 'black'
320
+
321
+ currency_string = JSON.parse(match[:currency_string])
322
+ format = (match[:prepend] == 'true' ? currency_string + format : format + currency_string)
323
+
324
+ if value < 0
325
+ value = value.abs
326
+ case match[:neg_style]
327
+ when '0' # MinusBlack
328
+ format = "-#{format}"
329
+ when '1' # Red
330
+ text_color = 'red'
331
+ when '2' # ParensBlack
332
+ format = "(#{format})"
333
+ when '3' # ParensRed
334
+ format = "(#{format})"
335
+ text_color = 'red'
336
+ end
337
+ end
338
+
339
+ [af_format_number(value, format, match[:sep_style]), text_color]
340
+ end
341
+
342
+ # Returns the appropriate JavaScript action string for the AFPercent_Format function.
343
+ #
344
+ # +decimals+::
345
+ # The number of decimal digits to use. Default 2.
346
+ #
347
+ # +separator_style+::
348
+ # Specifies the character for the decimal and thousands separator, one of:
349
+ #
350
+ # :point:: (Default) Use point as decimal separator and comma as thousands separator.
351
+ # :point_no_thousands:: Use point as decimal separator and no thousands separator.
352
+ # :comma:: Use comma as decimal separator and point as thousands separator.
353
+ # :comma_no_thousands:: Use comma as decimal separator and no thousands separator.
354
+ #
355
+ # See: #apply_af_percent_format
356
+ def af_percent_format_action(decimals: 2, separator_style: :point)
357
+ separator_style = AF_NUMBER_FORMAT_MAPPINGS[:separator].fetch(separator_style) do
358
+ raise ArgumentError, "Unsupported value for separator_style argument: #{separator_style}"
359
+ end
360
+ "AFPercent_Format(#{decimals}, #{separator_style});"
361
+ end
362
+
363
+ # Regular expression for matching the AFPercent_Format method.
364
+ #
365
+ # See: #apply_af_percent_format
366
+ AF_PERCENT_FORMAT_RE = /
367
+ \AAFPercent_Format\(
368
+ \s*(?<ndec>\d+)\s*,
369
+ \s*(?<sep_style>[0-3])\s*
370
+ \);?\z
371
+ /x
372
+
373
+ # Implements the JavaScript AFPercent_Format function and returns the formatted field value.
374
+ #
375
+ # The argument +value+ has to be the field's value (a String) and +action_string+ has to be
376
+ # the JavaScript action string.
377
+ #
378
+ # The AFPercent_Format function assumes that the text field's value contains a number (as a
379
+ # string) and formats it according to the instructions.
380
+ #
381
+ # It has the form <tt>AFPercent_Format(no_of_decimals, separator_style)</tt> where the
382
+ # arguments have the following meaning:
383
+ #
384
+ # +no_of_decimals+::
385
+ # The number of decimal places after the decimal point, e.g. for 3 it would result in
386
+ # 123.456.
387
+ #
388
+ # +separator_style+::
389
+ # Defines which decimal separator and whether a thousands separator should be used.
390
+ #
391
+ # Possible values are:
392
+ #
393
+ # +0+:: Comma for thousands separator, point for decimal separator: 12,345.67
394
+ # +1+:: No thousands separator, point for decimal separator: 12345.67
395
+ # +2+:: Point for thousands separator, comma for decimal separator: 12.345,67
396
+ # +3+:: No thousands separator, comma for decimal separator: 12345,67
397
+ def apply_af_percent_format(value, action_string)
398
+ return value unless (match = AF_PERCENT_FORMAT_RE.match(action_string))
399
+ af_format_number(af_make_number(value) * 100, "%.#{match[:ndec]}f%%", match[:sep_style])
400
+ end
401
+
402
+ AF_TIME_FORMAT_MAPPINGS = { #:nodoc:
403
+ format_integers: {
404
+ hh_mm: 0,
405
+ 0 => 0,
406
+ hh12_mm: 1,
407
+ 1 => 1,
408
+ hh_mm_ss: 2,
409
+ 2 => 2,
410
+ hh12_mm_ss: 3,
411
+ 3 => 3,
412
+ },
413
+ strftime_format: {
414
+ '0' => '%H:%M',
415
+ '1' => '%l:%M %p',
416
+ '2' => '%H:%M:%S',
417
+ '3' => '%l:%M:%S %p',
418
+ },
419
+ }
420
+
421
+ # Returns the appropriate JavaScript action string for the AFTime_Format function.
422
+ #
423
+ # +format+::
424
+ # Specifies the time format, one of:
425
+ #
426
+ # :hh_mm:: (Default) Use 24h time format %H:%M (e.g. 15:25)
427
+ # :hh12_mm:: (Default) Use 12h time format %l:%M %p (e.g. 3:25 PM)
428
+ # :hh_mm_ss:: Use 24h time format with seconds %H:%M:%S (e.g. 15:25:37)
429
+ # :hh12_mm_ss:: Use 24h time format with seconds %l:%M:%S %p (e.g. 3:25:37 PM)
430
+ #
431
+ # See: #apply_af_time_format
432
+ def af_time_format_action(format: :hh_mm)
433
+ format = AF_TIME_FORMAT_MAPPINGS[:format_integers].fetch(format) do
434
+ raise ArgumentError, "Unsupported value for time_format argument: #{format}"
435
+ end
436
+ "AFTime_Format(#{format});"
437
+ end
438
+
439
+ # Regular expression for matching the AFTime_Format method.
440
+ #
441
+ # See: #apply_af_time_format
442
+ AF_TIME_FORMAT_RE = /
443
+ \AAFTime_Format\(
444
+ \s*(?<time_format>[0-3])\s*
445
+ \);?\z
446
+ /x
447
+
448
+ # Implements the JavaScript AFTime_Format function and returns the formatted field value.
449
+ #
450
+ # The argument +value+ has to be the field's value (a String) and +action_string+ has to be
451
+ # the JavaScript action string.
452
+ #
453
+ # The AFTime_Format function assumes that the text field's value contains a valid time
454
+ # string (for HexaPDF that is anything Time.parse can work with) and formats it according to
455
+ # the instructions.
456
+ #
457
+ # It has the form <tt>AFTime_Format(time_format)</tt> where the argument has the following
458
+ # meaning:
459
+ #
460
+ # +time_format+::
461
+ # Defines the time format which should be applied.
462
+ #
463
+ # Possible values are:
464
+ #
465
+ # +0+:: Use 24h time format, e.g. 15:25
466
+ # +1+:: Use 12h time format, e.g. 3:25 PM
467
+ # +2+:: Use 24h time format with seconds, e.g. 15:25:37
468
+ # +3+:: Use 12h time format with seconds, e.g. 3:25:37 PM
469
+ def apply_af_time_format(value, action_string)
470
+ return value unless (match = AF_TIME_FORMAT_RE.match(action_string))
471
+ value = Time.parse(value) rescue nil
472
+ return "" unless value
473
+ value.strftime(AF_TIME_FORMAT_MAPPINGS[:strftime_format][match[:time_format]]).strip
474
+ end
475
+
476
+ # Handles JavaScript calculate actions for single-line text fields.
477
+ #
478
+ # The argument +form+ is the main Form instance of the document (needed for accessing the
479
+ # fields for the calculation) and +calculation_action+ is the PDF calculate action object
480
+ # that should be applied.
481
+ #
482
+ # Returns the calculated value as string if the calculation was succcessful or +nil+
483
+ # otherwise.
484
+ #
485
+ # A calculation may not be successful if
486
+ #
487
+ # * HexaPDF doesn't support the specific calculate action (e.g. because it contains general
488
+ # JavaScript instructions), or if
489
+ # * there was an error during the calculation (e.g. because a field could not be resolved).
490
+ def calculate(form, calculate_action)
491
+ return nil unless (action_string = action_string(calculate_action))
492
+ result = if action_string.start_with?('AFSimple_Calculate(')
493
+ run_af_simple_calculate(form, action_string)
494
+ elsif action_string.match?(/\/\*\*\s*BVCALC/)
495
+ run_simplified_field_notation(form, action_string)
496
+ else
497
+ nil
498
+ end
499
+ result && (result == result.truncate ? result.to_i.to_s : result.to_s)
500
+ end
501
+
502
+ AF_SIMPLE_CALCULATE_MAPPING = { #:nodoc:
503
+ sum: 'SUM',
504
+ average: 'AVG',
505
+ product: 'PRD',
506
+ min: 'MIN',
507
+ max: 'MAX',
508
+ }
509
+
510
+ # Returns the appropriate JavaScript action string for the AFSimple_Calculate function.
511
+ #
512
+ # +type+::
513
+ # The type of operation that should be used, one of:
514
+ #
515
+ # :sum:: Sums the values of the given +fields+.
516
+ # :average:: Calculates the average value of the given +fields+.
517
+ # :product:: Multiplies the values of the given +fields+.
518
+ # :min:: Uses the minimum value of the given +fields+.
519
+ # :max:: Uses the maximum value of the given +fields+.
520
+ #
521
+ # +fields+::
522
+ # An array of form field objects and/or full field names.
523
+ #
524
+ # See: #run_af_simple_calculate
525
+ def af_simple_calculate_action(type, fields)
526
+ fields = fields.map {|field| field.kind_of?(String) ? field : field.full_field_name }
527
+ "AFSimple_Calculate(\"#{AF_SIMPLE_CALCULATE_MAPPING[type]}\", #{fields.to_json});"
528
+ end
529
+
530
+ # Regular expression for matching the AFSimple_Calculate function.
531
+ #
532
+ # See: #run_af_simple_calculate
533
+ AF_SIMPLE_CALCULATE_RE = /
534
+ \AAFSimple_Calculate\(
535
+ \s*"(?<function>AVG|SUM|PRD|MIN|MAX)"\s*,
536
+ \s*(?<fields>.*)\s*
537
+ \);?\z
538
+ /x
539
+
540
+ # Mapping of AFSimple_Calculate function names to implementations.
541
+ #
542
+ # See: #run_af_simple_calculate
543
+ AF_SIMPLE_CALCULATE = {
544
+ 'AVG' => lambda {|values| values.sum / values.length },
545
+ 'SUM' => lambda {|values| values.sum },
546
+ 'PRD' => lambda {|values| values.inject {|product, val| product * val } },
547
+ 'MIN' => lambda {|values| values.min },
548
+ 'MAX' => lambda {|values| values.max },
549
+ }
550
+
551
+ # Implements the JavaScript AFSimple_Calculate function and returns the calculated value.
552
+ #
553
+ # The argument +form+ has to be the document's main AcroForm object and +action_string+ has
554
+ # to be the JavaScript action string.
555
+ #
556
+ # The AFSimple_Calculate function applies one of several predefined functions to the values
557
+ # of the given fields. The values of those fields need to be strings representing numbers.
558
+ #
559
+ # It has the form <tt>AFSimple_Calculate(function, fields))</tt> where the arguments have
560
+ # the following meaning:
561
+ #
562
+ # +function+::
563
+ # The name of the calculation function that should be applied to the values.
564
+ #
565
+ # Possible values are:
566
+ #
567
+ # +SUM+:: Calculate the sum of the given field values.
568
+ # +AVG+:: Calculate the average of the given field values.
569
+ # +PRD+:: Calculate the product of the given field values.
570
+ # +MIN+:: Calculate the minimum of the given field values.
571
+ # +MAX+:: Calculate the maximum of the given field values.
572
+ #
573
+ # +fields+::
574
+ # An array of AcroForm field names the values of which should be used.
575
+ def run_af_simple_calculate(form, action_string)
576
+ return nil unless (match = AF_SIMPLE_CALCULATE_RE.match(action_string))
577
+ function = match[:function]
578
+ values = match[:fields].scan(/".*?"/).map do |name|
579
+ return nil unless (field = form.field_by_name(name[1..-2]))
580
+ af_make_number(field.field_value)
581
+ end
582
+ AF_SIMPLE_CALCULATE.fetch(function)&.call(values)
583
+ end
584
+
585
+ # Returns the appropriate JavaScript action string for a calculate action that uses
586
+ # Simplified Field Notation.
587
+ #
588
+ # The argument +form+ has to be the document's main AcroForm object and +sfn_string+ the
589
+ # string containing the simplified field notation.
590
+ #
591
+ # See: #run_simplified_field_notation
592
+ def simplified_field_notation_action(form, sfn_string)
593
+ js_part = SimplifiedFieldNotationParser.new(form, sfn_string).parse(:generate)
594
+ raise ArgumentError, "Invalid simplified field notation rule" unless js_part
595
+ "/** BVCALC #{sfn_string} EVCALC **/ event.value = #{js_part}"
596
+ end
597
+
598
+ # Implements parsing of the simplified field notation (SFN).
599
+ #
600
+ # The argument +form+ has to be the document's main AcroForm object and +action_string+ has
601
+ # to be the JavaScript action string.
602
+ #
603
+ # This notation is more powerful than AFSimple_Calculate as it allows arbitrary expressions
604
+ # consisting of additions, substractions, multiplications and divisions, possibly grouped
605
+ # using parentheses, and field names (which stand in for their value) as well as numbers.
606
+ #
607
+ # Note: The implementation has been created by looking at sample documents using SFN. As
608
+ # such this may not work for all documents that use SFN.
609
+ def run_simplified_field_notation(form, action_string)
610
+ return nil unless (match = /BVCALC(.*?)EVCALC/m.match(action_string))
611
+ SimplifiedFieldNotationParser.new(form, match[1]).parse
612
+ end
613
+
614
+ # Returns the numeric value of the string, interpreting comma as point.
615
+ def af_make_number(value)
616
+ value.to_s.tr(',', '.').to_f
617
+ end
618
+
619
+ # Formats the numeric value according to the format string and separator style.
620
+ def af_format_number(value, format, sep_style)
621
+ result = sprintf(format, value)
622
+
623
+ before_decimal_point, after_decimal_point = result.split('.')
624
+ if sep_style == '0' || sep_style == '2'
625
+ separator = (sep_style == '0' ? ',' : '.')
626
+ before_decimal_point.gsub!(/\B(?=(\d\d\d)+(?:[^\d]|\z))/, separator)
627
+ end
628
+
629
+ if after_decimal_point
630
+ decimal_point = (sep_style <= "1" ? '.' : ',')
631
+ "#{before_decimal_point}#{decimal_point}#{after_decimal_point}"
632
+ else
633
+ before_decimal_point
634
+ end
635
+ end
636
+
637
+ # Returns the JavaScript action string for the given action.
638
+ def action_string(action)
639
+ return nil unless action && action[:S] == :JavaScript
640
+ result = action[:JS]
641
+ result.kind_of?(HexaPDF::Stream) ? result.stream : result
642
+ end
643
+ private :action_string
644
+
645
+ end
646
+
647
+ end
648
+ end
649
+ end