hexapdf 0.40.0 → 0.42.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 (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