acro_that 0.1.8 → 1.0.1

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.
@@ -0,0 +1,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcroThat
4
+ module Fields
5
+ # Base class for field types with shared functionality
6
+ module Base
7
+ include Actions::Base
8
+
9
+ attr_reader :document, :name, :options, :metadata, :field_type, :field_value
10
+
11
+ def initialize(document, name, options = {})
12
+ @document = document
13
+ @name = name
14
+ @options = normalize_hash_keys(options)
15
+ @metadata = normalize_hash_keys(@options[:metadata] || {})
16
+ @field_type = determine_field_type
17
+ @field_value = @options[:value] || ""
18
+ end
19
+
20
+ def x
21
+ @options[:x] || 100
22
+ end
23
+
24
+ def y
25
+ @options[:y] || 500
26
+ end
27
+
28
+ def width
29
+ @options[:width] || 100
30
+ end
31
+
32
+ def height
33
+ @options[:height] || 20
34
+ end
35
+
36
+ def page_num
37
+ @options[:page] || 1
38
+ end
39
+
40
+ private
41
+
42
+ def normalize_hash_keys(hash)
43
+ return hash unless hash.is_a?(Hash)
44
+
45
+ hash.each_with_object({}) do |(key, value), normalized|
46
+ sym_key = key.is_a?(Symbol) ? key : key.to_sym
47
+ normalized[sym_key] = value.is_a?(Hash) ? normalize_hash_keys(value) : value
48
+ end
49
+ end
50
+
51
+ def determine_field_type
52
+ type_input = @options[:type] || "/Tx"
53
+ case type_input
54
+ when :text, "text", "/Tx", "/tx"
55
+ "/Tx"
56
+ when :button, "button", "/Btn", "/btn"
57
+ "/Btn"
58
+ when :radio, "radio"
59
+ "/Btn"
60
+ when :checkbox, "checkbox"
61
+ "/Btn"
62
+ when :choice, "choice", "/Ch", "/ch"
63
+ "/Ch"
64
+ when :signature, "signature", "/Sig", "/sig"
65
+ "/Sig"
66
+ else
67
+ type_input.to_s
68
+ end
69
+ end
70
+
71
+ def create_field_dictionary(value, type)
72
+ dict = "<<\n"
73
+ dict += " /FT #{type}\n"
74
+ dict += " /T #{DictScan.encode_pdf_string(@name)}\n"
75
+
76
+ # Apply /Ff from metadata, or use default 0
77
+ field_flags = @metadata[:Ff] || 0
78
+ dict += " /Ff #{field_flags}\n"
79
+
80
+ dict += " /DA (/Helv 0 Tf 0 g)\n"
81
+
82
+ # Check if this is a radio button (has Radio flag set)
83
+ is_radio_field = field_flags.anybits?(32_768)
84
+
85
+ # For signature fields with image data, don't set /V (appearance stream will be added separately)
86
+ # For radio buttons, /V should be the export value name (e.g., "/email", "/phone")
87
+ # For checkboxes, set /V to normalized value (Yes/Off)
88
+ # For other fields, set /V normally
89
+ should_set_value = if type == "/Sig" && value && !value.empty?
90
+ !(value.is_a?(String) && (value.start_with?("data:image/") || (value.length > 50 && value.match?(%r{^[A-Za-z0-9+/]*={0,2}$}))))
91
+ else
92
+ true
93
+ end
94
+
95
+ # For radio buttons: use export value as PDF name (e.g., "/email")
96
+ # For checkboxes: normalize to "Yes" or "Off"
97
+ # For other fields: use value as-is
98
+ normalized_field_value = if is_radio_field && value && !value.to_s.empty?
99
+ # Encode export value as PDF name (escapes special characters like parentheses)
100
+ DictScan.encode_pdf_name(value)
101
+ elsif type == "/Btn" && value
102
+ value_str = value.to_s
103
+ is_checked = ["Yes", "/Yes", "true"].include?(value_str) || value == true
104
+ is_checked ? "Yes" : "Off"
105
+ else
106
+ value
107
+ end
108
+
109
+ # For radio buttons, /V should only be set if explicitly selected
110
+ # For checkboxes, /V should be a PDF name to match /AS format
111
+ # For other fields, encode as PDF string
112
+ if should_set_value && normalized_field_value && !normalized_field_value.to_s.empty?
113
+ # For radio buttons, only set /V if selected option is explicitly set to true
114
+ if is_radio_field
115
+ # Only set /V for radio buttons if selected option is true
116
+ if [true, "true"].include?(@options[:selected]) && normalized_field_value.to_s.start_with?("/")
117
+ dict += " /V #{normalized_field_value}\n"
118
+ end
119
+ elsif type == "/Btn"
120
+ # For checkboxes (button fields that aren't radio), encode value as PDF name
121
+ # to match the /AS appearance state format (/Yes or /Off)
122
+ dict += " /V #{DictScan.encode_pdf_name(normalized_field_value)}\n"
123
+ else
124
+ dict += " /V #{DictScan.encode_pdf_string(normalized_field_value)}\n"
125
+ end
126
+ end
127
+
128
+ # Apply other metadata entries (excluding Ff which we handled above)
129
+ @metadata.each do |key, val|
130
+ next if key == :Ff
131
+
132
+ pdf_key = DictScan.format_pdf_key(key)
133
+ pdf_value = DictScan.format_pdf_value(val)
134
+ dict += " #{pdf_key} #{pdf_value}\n"
135
+ end
136
+
137
+ dict += ">>"
138
+ dict
139
+ end
140
+
141
+ def create_widget_annotation_with_parent(_widget_obj_num, parent_ref, page_ref, x, y, width, height, type, value,
142
+ is_radio: false)
143
+ rect_array = "[#{x} #{y} #{x + width} #{y + height}]"
144
+ widget = "<<\n"
145
+ widget += " /Type /Annot\n"
146
+ widget += " /Subtype /Widget\n"
147
+ widget += " /Parent #{parent_ref[0]} #{parent_ref[1]} R\n"
148
+ widget += " /P #{page_ref[0]} #{page_ref[1]} R\n" if page_ref
149
+
150
+ widget += " /FT #{type}\n"
151
+ if is_radio
152
+ widget += " /T #{DictScan.encode_pdf_string(@name)}\n"
153
+ else
154
+
155
+ should_set_value = if type == "/Sig" && value && !value.empty?
156
+ !(value.is_a?(String) && (value.start_with?("data:image/") || (value.length > 50 && value.match?(%r{^[A-Za-z0-9+/]*={0,2}$}))))
157
+ elsif type == "/Btn"
158
+ true
159
+ else
160
+ true
161
+ end
162
+
163
+ if type == "/Btn" && should_set_value
164
+ # For checkboxes, encode value as PDF name to match /AS appearance state format
165
+ value_str = value.to_s
166
+ is_checked = ["Yes", "/Yes", "true"].include?(value_str) || value == true
167
+ checkbox_value = is_checked ? "Yes" : "Off"
168
+ widget += " /V #{DictScan.encode_pdf_name(checkbox_value)}\n"
169
+ elsif should_set_value && value && !value.empty?
170
+ widget += " /V #{DictScan.encode_pdf_string(value)}\n"
171
+ end
172
+ end
173
+
174
+ widget += " /Rect #{rect_array}\n"
175
+ widget += " /F 4\n"
176
+
177
+ widget += if is_radio
178
+ " /MK << /BC [0.0] /BG [1.0] >>\n"
179
+ else
180
+ " /DA (/Helv 0 Tf 0 g)\n"
181
+ end
182
+
183
+ @metadata.each do |key, val|
184
+ pdf_key = DictScan.format_pdf_key(key)
185
+ pdf_value = DictScan.format_pdf_value(val)
186
+ next if ["/F", "/V"].include?(pdf_key)
187
+ next if is_radio && ["/Ff", "/DA"].include?(pdf_key)
188
+
189
+ widget += " #{pdf_key} #{pdf_value}\n"
190
+ end
191
+
192
+ widget += ">>"
193
+ widget
194
+ end
195
+
196
+ def add_field_to_acroform_with_defaults(field_obj_num)
197
+ af_ref = acroform_ref
198
+ return false unless af_ref
199
+
200
+ af_body = get_object_body_with_patch(af_ref)
201
+ # Use +"" instead of dup to create a mutable copy without keeping reference to original
202
+ patched = af_body.to_s
203
+
204
+ # Step 1: Add field to /Fields array
205
+ fields_array_ref = DictScan.value_token_after("/Fields", patched)
206
+
207
+ if fields_array_ref && fields_array_ref =~ /\A(\d+)\s+(\d+)\s+R/
208
+ arr_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
209
+ arr_body = get_object_body_with_patch(arr_ref)
210
+ new_body = DictScan.add_ref_to_array(arr_body, [field_obj_num, 0])
211
+ apply_patch(arr_ref, new_body, arr_body)
212
+ elsif patched.include?("/Fields")
213
+ patched = DictScan.add_ref_to_inline_array(patched, "/Fields", [field_obj_num, 0])
214
+ else
215
+ patched = DictScan.upsert_key_value(patched, "/Fields", "[#{field_obj_num} 0 R]")
216
+ end
217
+
218
+ # Step 2: Ensure /NeedAppearances false (we provide custom appearance streams)
219
+ # Setting to false tells viewers to use our custom appearances instead of generating defaults
220
+ # If we don't set this or set it to true, viewers will ignore our custom appearances and
221
+ # generate their own default appearances (e.g., circular radio buttons instead of our squares)
222
+ patched = if patched.include?("/NeedAppearances")
223
+ # Replace existing /NeedAppearances with false
224
+ DictScan.replace_key_value(patched, "/NeedAppearances", "false")
225
+ else
226
+ DictScan.upsert_key_value(patched, "/NeedAppearances", "false")
227
+ end
228
+
229
+ # Step 2.5: Remove /XFA if present
230
+ if patched.include?("/XFA")
231
+ xfa_pattern = %r{/XFA(?=[\s(<\[/])}
232
+ if patched.match(xfa_pattern)
233
+ xfa_value = DictScan.value_token_after("/XFA", patched)
234
+ if xfa_value
235
+ xfa_match = patched.match(xfa_pattern)
236
+ if xfa_match
237
+ key_start = xfa_match.begin(0)
238
+ value_start = xfa_match.end(0)
239
+ value_start += 1 while value_start < patched.length && patched[value_start] =~ /\s/
240
+ value_end = value_start + xfa_value.length
241
+ value_end += 1 while value_end < patched.length && patched[value_end] =~ /\s/
242
+ before = patched[0...key_start]
243
+ before = before.rstrip
244
+ after = patched[value_end..]
245
+ patched = "#{before} #{after.lstrip}".strip
246
+ patched = patched.gsub(/\s+/, " ")
247
+ end
248
+ end
249
+ end
250
+ end
251
+
252
+ # Step 3: Ensure /DR /Font has /Helv mapping
253
+ unless patched.include?("/DR") && patched.include?("/Helv")
254
+ font_obj_num = next_fresh_object_number
255
+ font_body = "<<\n /Type /Font\n /Subtype /Type1\n /BaseFont /Helvetica\n>>"
256
+ document.instance_variable_get(:@patches) << { ref: [font_obj_num, 0], body: font_body }
257
+
258
+ if patched.include?("/DR")
259
+ dr_tok = DictScan.value_token_after("/DR", patched)
260
+ if dr_tok && dr_tok.start_with?("<<")
261
+ unless dr_tok.include?("/Font")
262
+ new_dr_tok = dr_tok.chomp(">>") + " /Font << /Helv #{font_obj_num} 0 R >>\n>>"
263
+ patched = patched.sub(dr_tok) { |_| new_dr_tok }
264
+ end
265
+ else
266
+ patched = DictScan.replace_key_value(patched, "/DR", "<< /Font << /Helv #{font_obj_num} 0 R >> >>")
267
+ end
268
+ else
269
+ patched = DictScan.upsert_key_value(patched, "/DR", "<< /Font << /Helv #{font_obj_num} 0 R >> >>")
270
+ end
271
+ end
272
+
273
+ apply_patch(af_ref, patched, af_body)
274
+ true
275
+ end
276
+
277
+ def find_page_ref(page_num)
278
+ find_page_by_number(page_num)
279
+ end
280
+
281
+ def add_widget_to_page(widget_obj_num, page_num)
282
+ target_page_ref = find_page_ref(page_num)
283
+ return false unless target_page_ref
284
+
285
+ page_body = get_object_body_with_patch(target_page_ref)
286
+
287
+ new_body = if page_body =~ %r{/Annots\s*\[(.*?)\]}m
288
+ result = DictScan.add_ref_to_inline_array(page_body, "/Annots", [widget_obj_num, 0])
289
+ if result && result != page_body
290
+ result
291
+ else
292
+ annots_array = ::Regexp.last_match(1)
293
+ ref_token = "#{widget_obj_num} 0 R"
294
+ new_annots = if annots_array.strip.empty?
295
+ "[#{ref_token}]"
296
+ else
297
+ "[#{annots_array} #{ref_token}]"
298
+ end
299
+ page_body.sub(%r{/Annots\s*\[.*?\]}, "/Annots #{new_annots}")
300
+ end
301
+ elsif page_body =~ %r{/Annots\s+(\d+)\s+(\d+)\s+R}
302
+ annots_array_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
303
+ annots_array_body = get_object_body_with_patch(annots_array_ref)
304
+
305
+ ref_token = "#{widget_obj_num} 0 R"
306
+ if annots_array_body
307
+ new_annots_body = if annots_array_body.strip == "[]"
308
+ "[#{ref_token}]"
309
+ elsif annots_array_body.strip.start_with?("[") && annots_array_body.strip.end_with?("]")
310
+ without_brackets = annots_array_body.strip[1..-2].strip
311
+ "[#{without_brackets} #{ref_token}]"
312
+ else
313
+ "[#{annots_array_body} #{ref_token}]"
314
+ end
315
+
316
+ apply_patch(annots_array_ref, new_annots_body, annots_array_body)
317
+ page_body
318
+ else
319
+ page_body.sub(%r{/Annots\s+\d+\s+\d+\s+R}, "/Annots [#{ref_token}]")
320
+ end
321
+ else
322
+ ref_token = "#{widget_obj_num} 0 R"
323
+ if page_body.include?(">>")
324
+ page_body.reverse.sub(">>".reverse, "/Annots [#{ref_token}]>>".reverse).reverse
325
+ else
326
+ page_body + " /Annots [#{ref_token}]"
327
+ end
328
+ end
329
+
330
+ apply_patch(target_page_ref, new_body, page_body) if new_body && new_body != page_body
331
+ true
332
+ end
333
+
334
+ def add_widget_to_parent_kids(parent_ref, widget_obj_num)
335
+ parent_body = get_object_body_with_patch(parent_ref)
336
+ return unless parent_body
337
+
338
+ kids_array_ref = DictScan.value_token_after("/Kids", parent_body)
339
+
340
+ if kids_array_ref && kids_array_ref =~ /\A(\d+)\s+(\d+)\s+R\z/
341
+ arr_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
342
+ arr_body = get_object_body_with_patch(arr_ref)
343
+ new_body = DictScan.add_ref_to_array(arr_body, [widget_obj_num, 0])
344
+ apply_patch(arr_ref, new_body, arr_body)
345
+ elsif kids_array_ref && kids_array_ref.start_with?("[")
346
+ new_body = DictScan.add_ref_to_inline_array(parent_body, "/Kids", [widget_obj_num, 0])
347
+ apply_patch(parent_ref, new_body, parent_body) if new_body && new_body != parent_body
348
+ else
349
+ new_body = DictScan.upsert_key_value(parent_body, "/Kids", "[#{widget_obj_num} 0 R]")
350
+ apply_patch(parent_ref, new_body, parent_body) if new_body && new_body != parent_body
351
+ end
352
+ end
353
+
354
+ def build_form_xobject(content_stream, width, height)
355
+ dict = "<<\n"
356
+ dict += " /Type /XObject\n"
357
+ dict += " /Subtype /Form\n"
358
+ dict += " /BBox [0 0 #{width} #{height}]\n"
359
+ dict += " /Matrix [1 0 0 1 0 0]\n"
360
+ dict += " /Resources << >>\n"
361
+ dict += " /Length #{content_stream.bytesize}\n"
362
+ dict += ">>\n"
363
+ dict += "stream\n"
364
+ dict += content_stream
365
+ dict += "\nendstream"
366
+
367
+ dict
368
+ end
369
+ end
370
+ end
371
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcroThat
4
+ module Fields
5
+ # Handles checkbox field creation
6
+ class Checkbox
7
+ include Base
8
+
9
+ attr_reader :field_obj_num
10
+
11
+ def call
12
+ @field_obj_num = next_fresh_object_number
13
+ widget_obj_num = @field_obj_num + 1
14
+
15
+ field_body = create_field_dictionary(@field_value, @field_type)
16
+ page_ref = find_page_ref(page_num)
17
+
18
+ widget_body = create_widget_annotation_with_parent(widget_obj_num, [@field_obj_num, 0], page_ref, x, y, width,
19
+ height, @field_type, @field_value)
20
+
21
+ document.instance_variable_get(:@patches) << { ref: [@field_obj_num, 0], body: field_body }
22
+ document.instance_variable_get(:@patches) << { ref: [widget_obj_num, 0], body: widget_body }
23
+
24
+ add_field_to_acroform_with_defaults(@field_obj_num)
25
+ add_widget_to_page(widget_obj_num, page_num)
26
+
27
+ add_checkbox_appearance(widget_obj_num)
28
+
29
+ true
30
+ end
31
+
32
+ private
33
+
34
+ def add_checkbox_appearance(widget_obj_num)
35
+ yes_obj_num = next_fresh_object_number
36
+ off_obj_num = yes_obj_num + 1
37
+
38
+ yes_body = create_checkbox_yes_appearance(width, height)
39
+ document.instance_variable_get(:@patches) << { ref: [yes_obj_num, 0], body: yes_body }
40
+
41
+ off_body = create_checkbox_off_appearance(width, height)
42
+ document.instance_variable_get(:@patches) << { ref: [off_obj_num, 0], body: off_body }
43
+
44
+ widget_ref = [widget_obj_num, 0]
45
+ original_widget_body = get_object_body_with_patch(widget_ref)
46
+ # Use +"" instead of dup to create a mutable copy without keeping reference to original
47
+ widget_body = original_widget_body.to_s
48
+
49
+ ap_dict = "<<\n /N <<\n /Yes #{yes_obj_num} 0 R\n /Off #{off_obj_num} 0 R\n >>\n>>"
50
+
51
+ widget_body = if widget_body.include?("/AP")
52
+ DictScan.replace_key_value(widget_body, "/AP", ap_dict)
53
+ else
54
+ DictScan.upsert_key_value(widget_body, "/AP", ap_dict)
55
+ end
56
+
57
+ value_str = @field_value.to_s
58
+ is_checked = value_str == "Yes" || value_str == "/Yes" || value_str == "true" || @field_value == true
59
+ normalized_value = is_checked ? "Yes" : "Off"
60
+
61
+ # Set /V to match /AS - both should be PDF names for checkboxes
62
+ v_value = DictScan.encode_pdf_name(normalized_value)
63
+
64
+ as_value = if normalized_value == "Yes"
65
+ "/Yes"
66
+ else
67
+ "/Off"
68
+ end
69
+
70
+ # Update /V to ensure it matches /AS format (both PDF names)
71
+ widget_body = if widget_body.include?("/V")
72
+ DictScan.replace_key_value(widget_body, "/V", v_value)
73
+ else
74
+ DictScan.upsert_key_value(widget_body, "/V", v_value)
75
+ end
76
+
77
+ # Update /AS to match the normalized value
78
+ widget_body = if widget_body.include?("/AS")
79
+ DictScan.replace_key_value(widget_body, "/AS", as_value)
80
+ else
81
+ DictScan.upsert_key_value(widget_body, "/AS", as_value)
82
+ end
83
+
84
+ apply_patch(widget_ref, widget_body, original_widget_body)
85
+ end
86
+
87
+ def create_checkbox_yes_appearance(width, height)
88
+ line_width = [width * 0.05, height * 0.05].min
89
+ border_width = [width * 0.08, height * 0.08].min
90
+
91
+ # Define checkmark in normalized coordinates (0-1 range) for consistent aspect ratio
92
+ # Checkmark shape: three points forming a checkmark
93
+ norm_x1 = 0.25
94
+ norm_y1 = 0.55
95
+ norm_x2 = 0.45
96
+ norm_y2 = 0.35
97
+ norm_x3 = 0.75
98
+ norm_y3 = 0.85
99
+
100
+ # Calculate scale to maximize size while maintaining aspect ratio
101
+ # Use the smaller dimension to ensure it fits
102
+ scale = [width, height].min * 0.85 # Use 85% of the smaller dimension
103
+
104
+ # Calculate checkmark dimensions
105
+ check_width = scale
106
+ check_height = scale
107
+
108
+ # Center the checkmark in the box
109
+ offset_x = (width - check_width) / 2
110
+ offset_y = (height - check_height) / 2
111
+
112
+ # Calculate actual coordinates
113
+ check_x1 = offset_x + norm_x1 * check_width
114
+ check_y1 = offset_y + norm_y1 * check_height
115
+ check_x2 = offset_x + norm_x2 * check_width
116
+ check_y2 = offset_y + norm_y2 * check_height
117
+ check_x3 = offset_x + norm_x3 * check_width
118
+ check_y3 = offset_y + norm_y3 * check_height
119
+
120
+ content_stream = "q\n"
121
+ # Draw square border around field bounds
122
+ content_stream += "0 0 0 RG\n" # Black stroke color
123
+ content_stream += "#{line_width} w\n" # Line width
124
+ # Draw rectangle from (0,0) to (width, height)
125
+ content_stream += "0 0 m\n"
126
+ content_stream += "#{width} 0 l\n"
127
+ content_stream += "#{width} #{height} l\n"
128
+ content_stream += "0 #{height} l\n"
129
+ content_stream += "0 0 l\n"
130
+ content_stream += "S\n" # Stroke the border
131
+
132
+ # Draw checkmark
133
+ content_stream += "0 0 0 rg\n" # Black fill color
134
+ content_stream += "#{border_width} w\n" # Line width for checkmark
135
+ content_stream += "#{check_x1} #{check_y1} m\n"
136
+ content_stream += "#{check_x2} #{check_y2} l\n"
137
+ content_stream += "#{check_x3} #{check_y3} l\n"
138
+ content_stream += "S\n" # Stroke the checkmark
139
+ content_stream += "Q\n"
140
+
141
+ build_form_xobject(content_stream, width, height)
142
+ end
143
+
144
+ def create_checkbox_off_appearance(width, height)
145
+ line_width = [width * 0.05, height * 0.05].min
146
+
147
+ content_stream = "q\n"
148
+ # Draw square border around field bounds
149
+ content_stream += "0 0 0 RG\n" # Black stroke color
150
+ content_stream += "#{line_width} w\n" # Line width
151
+ # Draw rectangle from (0,0) to (width, height)
152
+ content_stream += "0 0 m\n"
153
+ content_stream += "#{width} 0 l\n"
154
+ content_stream += "#{width} #{height} l\n"
155
+ content_stream += "0 #{height} l\n"
156
+ content_stream += "0 0 l\n"
157
+ content_stream += "S\n" # Stroke the border
158
+ content_stream += "Q\n"
159
+
160
+ build_form_xobject(content_stream, width, height)
161
+ end
162
+ end
163
+ end
164
+ end