acro_that 0.1.7 → 1.0.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/Gemfile.lock +1 -1
- data/README.md +86 -3
- data/docs/clear_fields.md +10 -10
- data/lib/acro_that/actions/add_field.rb +30 -447
- data/lib/acro_that/actions/update_field.rb +89 -14
- data/lib/acro_that/dict_scan.rb +26 -0
- data/lib/acro_that/document.rb +63 -17
- data/lib/acro_that/fields/base.rb +365 -0
- data/lib/acro_that/fields/checkbox.rb +131 -0
- data/lib/acro_that/fields/radio.rb +198 -0
- data/lib/acro_that/{actions/add_signature_appearance.rb → fields/signature.rb} +64 -93
- data/lib/acro_that/fields/text.rb +31 -0
- data/lib/acro_that/version.rb +1 -1
- data/lib/acro_that.rb +10 -2
- metadata +7 -3
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module AcroThat
|
|
4
4
|
module Actions
|
|
5
5
|
# Action to add a new field to a PDF document
|
|
6
|
+
# Delegates to field-specific classes for actual field creation
|
|
6
7
|
class AddField
|
|
7
8
|
include Base
|
|
8
9
|
|
|
@@ -11,477 +12,59 @@ module AcroThat
|
|
|
11
12
|
def initialize(document, name, options = {})
|
|
12
13
|
@document = document
|
|
13
14
|
@name = name
|
|
14
|
-
@options = options
|
|
15
|
-
@metadata = options[:metadata] || {}
|
|
15
|
+
@options = normalize_hash_keys(options)
|
|
16
|
+
@metadata = normalize_hash_keys(@options[:metadata] || {})
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def call
|
|
19
|
-
x = @options[:x] || 100
|
|
20
|
-
y = @options[:y] || 500
|
|
21
|
-
width = @options[:width] || 100
|
|
22
|
-
height = @options[:height] || 20
|
|
23
|
-
page_num = @options[:page] || 1
|
|
24
|
-
|
|
25
|
-
# Normalize field type: accept symbols or strings, convert to PDF format
|
|
26
20
|
type_input = @options[:type] || "/Tx"
|
|
27
|
-
@
|
|
28
|
-
when :text, "text", "/Tx", "/tx"
|
|
29
|
-
"/Tx"
|
|
30
|
-
when :button, "button", "/Btn", "/btn"
|
|
31
|
-
"/Btn"
|
|
32
|
-
when :radio, "radio"
|
|
33
|
-
"/Btn"
|
|
34
|
-
when :checkbox, "checkbox"
|
|
35
|
-
"/Btn"
|
|
36
|
-
when :choice, "choice", "/Ch", "/ch"
|
|
37
|
-
"/Ch"
|
|
38
|
-
when :signature, "signature", "/Sig", "/sig"
|
|
39
|
-
"/Sig"
|
|
40
|
-
else
|
|
41
|
-
type_input.to_s # Use as-is if it's already in PDF format
|
|
42
|
-
end
|
|
43
|
-
@field_value = @options[:value] || ""
|
|
21
|
+
@options[:group_id]
|
|
44
22
|
|
|
45
23
|
# Auto-set radio button flags if type is :radio and flags not explicitly set
|
|
46
|
-
#
|
|
47
|
-
if [:radio, "radio"].include?(type_input) &&
|
|
24
|
+
# MUST set this BEFORE creating the field handler so it gets passed correctly
|
|
25
|
+
if [:radio, "radio"].include?(type_input) && !@metadata[:Ff]
|
|
48
26
|
@metadata[:Ff] = 49_152
|
|
49
27
|
end
|
|
50
28
|
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
widget_obj_num = @field_obj_num + 1
|
|
54
|
-
|
|
55
|
-
field_body = create_field_dictionary(@field_value, @field_type)
|
|
56
|
-
|
|
57
|
-
# Find the page ref for /P on widget (must happen before we create patches)
|
|
58
|
-
page_ref = find_page_ref(page_num)
|
|
59
|
-
|
|
60
|
-
# Create widget with page reference
|
|
61
|
-
widget_body = create_widget_annotation_with_parent(widget_obj_num, [@field_obj_num, 0], page_ref, x, y, width,
|
|
62
|
-
height, @field_type, @field_value)
|
|
63
|
-
|
|
64
|
-
# Queue objects
|
|
65
|
-
@document.instance_variable_get(:@patches) << { ref: [@field_obj_num, 0], body: field_body }
|
|
66
|
-
@document.instance_variable_get(:@patches) << { ref: [widget_obj_num, 0], body: widget_body }
|
|
29
|
+
# Determine field type and create appropriate field handler
|
|
30
|
+
field_handler = create_field_handler(type_input)
|
|
67
31
|
|
|
68
|
-
#
|
|
69
|
-
|
|
32
|
+
# Call the field handler
|
|
33
|
+
field_handler.call
|
|
70
34
|
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if @field_type == "/Sig" && @field_value && !@field_value.empty?
|
|
76
|
-
image_data = @field_value
|
|
77
|
-
# Check if value looks like base64 image data or data URI (same logic as update_field)
|
|
78
|
-
if image_data.is_a?(String) && (image_data.start_with?("data:image/") || (image_data.length > 50 && image_data.match?(%r{^[A-Za-z0-9+/]*={0,2}$})))
|
|
79
|
-
field_ref = [@field_obj_num, 0]
|
|
80
|
-
# Try adding signature appearance - use width and height from options
|
|
81
|
-
action = Actions::AddSignatureAppearance.new(@document, field_ref, image_data, width: width, height: height)
|
|
82
|
-
# NOTE: We don't fail if appearance addition fails - field was still created successfully
|
|
83
|
-
action.call
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# If this is a checkbox (button field that's not a radio button), add appearance dictionaries
|
|
88
|
-
# Button fields can be checkboxes or radio buttons:
|
|
89
|
-
# - Radio buttons have Radio flag (bit 15 = 32768) set
|
|
90
|
-
# - Checkboxes don't have Radio flag set
|
|
91
|
-
is_checkbox = false
|
|
92
|
-
if @field_type == "/Btn"
|
|
93
|
-
field_flags = (@metadata[:Ff] || @metadata["Ff"] || 0).to_i
|
|
94
|
-
is_radio = field_flags.anybits?(32_768) || [:radio, "radio"].include?(type_input)
|
|
95
|
-
is_checkbox = !is_radio
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
if is_checkbox
|
|
99
|
-
add_checkbox_appearance(widget_obj_num, x, y, width, height)
|
|
100
|
-
end
|
|
35
|
+
# Store field_obj_num from handler for compatibility
|
|
36
|
+
@field_obj_num = field_handler.field_obj_num
|
|
37
|
+
@field_type = field_handler.field_type
|
|
38
|
+
@field_value = field_handler.field_value
|
|
101
39
|
|
|
102
40
|
true
|
|
103
41
|
end
|
|
104
42
|
|
|
105
43
|
private
|
|
106
44
|
|
|
107
|
-
def
|
|
108
|
-
|
|
109
|
-
dict += " /FT #{type}\n"
|
|
110
|
-
dict += " /T #{DictScan.encode_pdf_string(@name)}\n"
|
|
111
|
-
|
|
112
|
-
# Apply /Ff from metadata, or use default 0
|
|
113
|
-
# Note: Radio button flags should already be set in metadata during type normalization
|
|
114
|
-
field_flags = @metadata[:Ff] || @metadata["Ff"] || 0
|
|
115
|
-
dict += " /Ff #{field_flags}\n"
|
|
116
|
-
|
|
117
|
-
dict += " /DA (/Helv 0 Tf 0 g)\n"
|
|
118
|
-
|
|
119
|
-
# For signature fields with image data, don't set /V (appearance stream will be added separately)
|
|
120
|
-
# For checkboxes/radio buttons, set /V to normalized value (Yes/Off) - macOS Preview needs this
|
|
121
|
-
# For other fields, set /V normally
|
|
122
|
-
should_set_value = if type == "/Sig" && value && !value.empty?
|
|
123
|
-
# Check if value looks like image data
|
|
124
|
-
!(value.is_a?(String) && (value.start_with?("data:image/") || (value.length > 50 && value.match?(%r{^[A-Za-z0-9+/]*={0,2}$}))))
|
|
125
|
-
else
|
|
126
|
-
true
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# For button fields (checkboxes/radio), normalize value to "Yes" or "Off"
|
|
130
|
-
normalized_field_value = if type == "/Btn" && value
|
|
131
|
-
# Accept "Yes", "/Yes" (PDF name format), true (boolean), or "true" (string)
|
|
132
|
-
value_str = value.to_s
|
|
133
|
-
is_checked = ["Yes", "/Yes", "true"].include?(value_str) || value == true
|
|
134
|
-
is_checked ? "Yes" : "Off"
|
|
135
|
-
else
|
|
136
|
-
value
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
dict += " /V #{DictScan.encode_pdf_string(normalized_field_value)}\n" if should_set_value && normalized_field_value && !normalized_field_value.to_s.empty?
|
|
140
|
-
|
|
141
|
-
# Apply other metadata entries (excluding Ff which we handled above)
|
|
142
|
-
@metadata.each do |key, val|
|
|
143
|
-
next if [:Ff, "Ff"].include?(key) # Already handled above
|
|
144
|
-
|
|
145
|
-
pdf_key = DictScan.format_pdf_key(key)
|
|
146
|
-
pdf_value = DictScan.format_pdf_value(val)
|
|
147
|
-
dict += " #{pdf_key} #{pdf_value}\n"
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
dict += ">>"
|
|
151
|
-
dict
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def create_widget_annotation_with_parent(_widget_obj_num, parent_ref, page_ref, x, y, width, height, type, value)
|
|
155
|
-
rect_array = "[#{x} #{y} #{x + width} #{y + height}]"
|
|
156
|
-
widget = "<<\n"
|
|
157
|
-
widget += " /Type /Annot\n"
|
|
158
|
-
widget += " /Subtype /Widget\n"
|
|
159
|
-
widget += " /Parent #{parent_ref[0]} #{parent_ref[1]} R\n"
|
|
160
|
-
widget += " /P #{page_ref[0]} #{page_ref[1]} R\n" if page_ref
|
|
161
|
-
widget += " /FT #{type}\n"
|
|
162
|
-
widget += " /Rect #{rect_array}\n"
|
|
163
|
-
widget += " /F 4\n"
|
|
164
|
-
widget += " /DA (/Helv 0 Tf 0 g)\n"
|
|
165
|
-
|
|
166
|
-
# For checkboxes, /V is set to "Yes" or "Off" and /AS is set accordingly
|
|
167
|
-
# For signature fields with image data, don't set /V (appearance stream will be added separately)
|
|
168
|
-
# For other fields or non-image signature values, set /V normally
|
|
169
|
-
should_set_value = if type == "/Sig" && value && !value.empty?
|
|
170
|
-
# Check if value looks like image data
|
|
171
|
-
!(value.is_a?(String) && (value.start_with?("data:image/") || (value.length > 50 && value.match?(%r{^[A-Za-z0-9+/]*={0,2}$}))))
|
|
172
|
-
elsif type == "/Btn"
|
|
173
|
-
# For button fields (checkboxes), set /V to "Yes" or "Off"
|
|
174
|
-
# This will be handled by checkbox appearance code, but we set it here for consistency
|
|
175
|
-
true
|
|
176
|
-
else
|
|
177
|
-
true
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# For checkboxes, set /V to "Yes" or empty/Off
|
|
181
|
-
if type == "/Btn" && should_set_value
|
|
182
|
-
# Checkbox value should be "Yes" if checked, otherwise empty or "Off"
|
|
183
|
-
# Accept "Yes", "/Yes" (PDF name format), true (boolean), or "true" (string)
|
|
184
|
-
value_str = value.to_s
|
|
185
|
-
is_checked = ["Yes", "/Yes", "true"].include?(value_str) || value == true
|
|
186
|
-
checkbox_value = is_checked ? "Yes" : "Off"
|
|
187
|
-
widget += " /V #{DictScan.encode_pdf_string(checkbox_value)}\n"
|
|
188
|
-
elsif should_set_value && value && !value.empty?
|
|
189
|
-
widget += " /V #{DictScan.encode_pdf_string(value)}\n"
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
# Apply metadata entries that are valid for widgets
|
|
193
|
-
# Common widget properties: /Q (alignment), /Ff (field flags), /BS (border style), etc.
|
|
194
|
-
@metadata.each do |key, val|
|
|
195
|
-
pdf_key = DictScan.format_pdf_key(key)
|
|
196
|
-
pdf_value = DictScan.format_pdf_value(val)
|
|
197
|
-
# Only add if not already present (we've added /F above, /V above if value exists)
|
|
198
|
-
next if ["/F", "/V"].include?(pdf_key)
|
|
199
|
-
|
|
200
|
-
widget += " #{pdf_key} #{pdf_value}\n"
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
widget += ">>"
|
|
204
|
-
widget
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def add_field_to_acroform_with_defaults(field_obj_num)
|
|
208
|
-
af_ref = acroform_ref
|
|
209
|
-
return false unless af_ref
|
|
210
|
-
|
|
211
|
-
af_body = get_object_body_with_patch(af_ref)
|
|
212
|
-
|
|
213
|
-
patched = af_body.dup
|
|
214
|
-
|
|
215
|
-
# Step 1: Add field to /Fields array
|
|
216
|
-
fields_array_ref = DictScan.value_token_after("/Fields", patched)
|
|
217
|
-
|
|
218
|
-
if fields_array_ref && fields_array_ref =~ /\A(\d+)\s+(\d+)\s+R/
|
|
219
|
-
# Reference case: /Fields points to a separate array object
|
|
220
|
-
arr_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
|
|
221
|
-
arr_body = get_object_body_with_patch(arr_ref)
|
|
222
|
-
new_body = DictScan.add_ref_to_array(arr_body, [field_obj_num, 0])
|
|
223
|
-
apply_patch(arr_ref, new_body, arr_body)
|
|
224
|
-
elsif patched.include?("/Fields")
|
|
225
|
-
# Inline array case: use DictScan utility
|
|
226
|
-
patched = DictScan.add_ref_to_inline_array(patched, "/Fields", [field_obj_num, 0])
|
|
227
|
-
else
|
|
228
|
-
# No /Fields exists - add it with the field reference
|
|
229
|
-
patched = DictScan.upsert_key_value(patched, "/Fields", "[#{field_obj_num} 0 R]")
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
# Step 2: Ensure /NeedAppearances true
|
|
233
|
-
unless patched.include?("/NeedAppearances")
|
|
234
|
-
patched = DictScan.upsert_key_value(patched, "/NeedAppearances", "true")
|
|
235
|
-
end
|
|
45
|
+
def normalize_hash_keys(hash)
|
|
46
|
+
return hash unless hash.is_a?(Hash)
|
|
236
47
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
xfa_pattern = %r{/XFA(?=[\s(<\[/])}
|
|
241
|
-
if patched.match(xfa_pattern)
|
|
242
|
-
# Try to get the value token to determine what we're removing
|
|
243
|
-
xfa_value = DictScan.value_token_after("/XFA", patched)
|
|
244
|
-
if xfa_value
|
|
245
|
-
# Remove /XFA by replacing it with an empty string
|
|
246
|
-
# We'll use a simple approach: find the key and remove it with its value
|
|
247
|
-
xfa_match = patched.match(xfa_pattern)
|
|
248
|
-
if xfa_match
|
|
249
|
-
# Find the start and end of /XFA and its value
|
|
250
|
-
key_start = xfa_match.begin(0)
|
|
251
|
-
# Skip /XFA key
|
|
252
|
-
value_start = xfa_match.end(0)
|
|
253
|
-
value_start += 1 while value_start < patched.length && patched[value_start] =~ /\s/
|
|
254
|
-
# Use value_token_after to get the complete value token
|
|
255
|
-
# We already have xfa_value, so calculate its end
|
|
256
|
-
value_end = value_start + xfa_value.length
|
|
257
|
-
# Skip trailing whitespace
|
|
258
|
-
value_end += 1 while value_end < patched.length && patched[value_end] =~ /\s/
|
|
259
|
-
# Remove /XFA and its value
|
|
260
|
-
before = patched[0...key_start]
|
|
261
|
-
# Remove any whitespace before /XFA too (but not the opening <<)
|
|
262
|
-
before = before.rstrip
|
|
263
|
-
after = patched[value_end..]
|
|
264
|
-
patched = "#{before} #{after.lstrip}".strip
|
|
265
|
-
# Clean up any double spaces
|
|
266
|
-
patched = patched.gsub(/\s+/, " ")
|
|
267
|
-
end
|
|
268
|
-
end
|
|
269
|
-
end
|
|
48
|
+
hash.each_with_object({}) do |(key, value), normalized|
|
|
49
|
+
sym_key = key.is_a?(Symbol) ? key : key.to_sym
|
|
50
|
+
normalized[sym_key] = value.is_a?(Hash) ? normalize_hash_keys(value) : value
|
|
270
51
|
end
|
|
271
|
-
|
|
272
|
-
# Step 3: Ensure /DR /Font has /Helv mapping
|
|
273
|
-
unless patched.include?("/DR") && patched.include?("/Helv")
|
|
274
|
-
font_obj_num = next_fresh_object_number
|
|
275
|
-
font_body = "<<\n /Type /Font\n /Subtype /Type1\n /BaseFont /Helvetica\n>>"
|
|
276
|
-
patches << { ref: [font_obj_num, 0], body: font_body }
|
|
277
|
-
|
|
278
|
-
if patched.include?("/DR")
|
|
279
|
-
# /DR exists - try to add /Font if it doesn't exist
|
|
280
|
-
dr_tok = DictScan.value_token_after("/DR", patched)
|
|
281
|
-
if dr_tok && dr_tok.start_with?("<<")
|
|
282
|
-
# Check if /Font already exists in /DR
|
|
283
|
-
unless dr_tok.include?("/Font")
|
|
284
|
-
# Add /Font to existing /DR dictionary
|
|
285
|
-
new_dr_tok = dr_tok.chomp(">>") + " /Font << /Helv #{font_obj_num} 0 R >>\n>>"
|
|
286
|
-
patched = patched.sub(dr_tok) { |_| new_dr_tok }
|
|
287
|
-
end
|
|
288
|
-
else
|
|
289
|
-
# /DR exists but isn't a dictionary - replace it
|
|
290
|
-
patched = DictScan.replace_key_value(patched, "/DR", "<< /Font << /Helv #{font_obj_num} 0 R >> >>")
|
|
291
|
-
end
|
|
292
|
-
else
|
|
293
|
-
# No /DR exists - add it
|
|
294
|
-
patched = DictScan.upsert_key_value(patched, "/DR", "<< /Font << /Helv #{font_obj_num} 0 R >> >>")
|
|
295
|
-
end
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
apply_patch(af_ref, patched, af_body)
|
|
299
|
-
true
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
def find_page_ref(page_num)
|
|
303
|
-
# Use Document's unified page-finding method
|
|
304
|
-
find_page_by_number(page_num)
|
|
305
52
|
end
|
|
306
53
|
|
|
307
|
-
def
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
return false unless target_page_ref
|
|
311
|
-
|
|
312
|
-
page_body = get_object_body_with_patch(target_page_ref)
|
|
313
|
-
|
|
314
|
-
# Use DictScan utility to safely add reference to /Annots array
|
|
315
|
-
new_body = if page_body =~ %r{/Annots\s*\[(.*?)\]}m
|
|
316
|
-
# Inline array - add to it
|
|
317
|
-
result = DictScan.add_ref_to_inline_array(page_body, "/Annots", [widget_obj_num, 0])
|
|
318
|
-
if result && result != page_body
|
|
319
|
-
result
|
|
320
|
-
else
|
|
321
|
-
# Fallback: use string manipulation
|
|
322
|
-
annots_array = ::Regexp.last_match(1)
|
|
323
|
-
ref_token = "#{widget_obj_num} 0 R"
|
|
324
|
-
new_annots = if annots_array.strip.empty?
|
|
325
|
-
"[#{ref_token}]"
|
|
326
|
-
else
|
|
327
|
-
"[#{annots_array} #{ref_token}]"
|
|
328
|
-
end
|
|
329
|
-
page_body.sub(%r{/Annots\s*\[.*?\]}, "/Annots #{new_annots}")
|
|
330
|
-
end
|
|
331
|
-
elsif page_body =~ %r{/Annots\s+(\d+)\s+(\d+)\s+R}
|
|
332
|
-
# Indirect array reference - need to read and modify the array object
|
|
333
|
-
annots_array_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
|
|
334
|
-
annots_array_body = get_object_body_with_patch(annots_array_ref)
|
|
335
|
-
|
|
336
|
-
ref_token = "#{widget_obj_num} 0 R"
|
|
337
|
-
if annots_array_body
|
|
338
|
-
new_annots_body = if annots_array_body.strip == "[]"
|
|
339
|
-
"[#{ref_token}]"
|
|
340
|
-
elsif annots_array_body.strip.start_with?("[") && annots_array_body.strip.end_with?("]")
|
|
341
|
-
without_brackets = annots_array_body.strip[1..-2].strip
|
|
342
|
-
"[#{without_brackets} #{ref_token}]"
|
|
343
|
-
else
|
|
344
|
-
"[#{annots_array_body} #{ref_token}]"
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
apply_patch(annots_array_ref, new_annots_body, annots_array_body)
|
|
348
|
-
|
|
349
|
-
# Page body doesn't need to change (still references the same array object)
|
|
350
|
-
page_body
|
|
351
|
-
else
|
|
352
|
-
# Array object not found - fallback to creating inline array
|
|
353
|
-
page_body.sub(%r{/Annots\s+\d+\s+\d+\s+R}, "/Annots [#{ref_token}]")
|
|
354
|
-
end
|
|
355
|
-
else
|
|
356
|
-
# No /Annots exists - add it with the widget reference
|
|
357
|
-
# Insert /Annots before the closing >> of the dictionary
|
|
358
|
-
ref_token = "#{widget_obj_num} 0 R"
|
|
359
|
-
if page_body.include?(">>")
|
|
360
|
-
# Find the last >> (closing the outermost dictionary) and insert /Annots before it
|
|
361
|
-
page_body.reverse.sub(">>".reverse, "/Annots [#{ref_token}]>>".reverse).reverse
|
|
362
|
-
else
|
|
363
|
-
page_body + " /Annots [#{ref_token}]"
|
|
364
|
-
end
|
|
365
|
-
end
|
|
366
|
-
|
|
367
|
-
apply_patch(target_page_ref, new_body, page_body) if new_body && new_body != page_body
|
|
368
|
-
true
|
|
369
|
-
end
|
|
370
|
-
|
|
371
|
-
def add_checkbox_appearance(widget_obj_num, _x, _y, width, height)
|
|
372
|
-
# Create appearance form XObjects for Yes and Off states
|
|
373
|
-
yes_obj_num = next_fresh_object_number
|
|
374
|
-
off_obj_num = yes_obj_num + 1
|
|
375
|
-
|
|
376
|
-
# Create Yes appearance (checked box with checkmark)
|
|
377
|
-
yes_body = create_checkbox_yes_appearance(width, height)
|
|
378
|
-
@document.instance_variable_get(:@patches) << { ref: [yes_obj_num, 0], body: yes_body }
|
|
379
|
-
|
|
380
|
-
# Create Off appearance (empty box)
|
|
381
|
-
off_body = create_checkbox_off_appearance(width, height)
|
|
382
|
-
@document.instance_variable_get(:@patches) << { ref: [off_obj_num, 0], body: off_body }
|
|
54
|
+
def create_field_handler(type_input)
|
|
55
|
+
is_radio = [:radio, "radio"].include?(type_input)
|
|
56
|
+
group_id = @options[:group_id]
|
|
383
57
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
ap_dict = "<<\n /N <<\n /Yes #{yes_obj_num} 0 R\n /Off #{off_obj_num} 0 R\n >>\n>>"
|
|
391
|
-
|
|
392
|
-
# Add /AP to widget
|
|
393
|
-
if widget_body.include?("/AP")
|
|
394
|
-
# Replace existing /AP
|
|
395
|
-
ap_key_pattern = %r{/AP(?=[\s(<\[/])}
|
|
396
|
-
if widget_body.match(ap_key_pattern)
|
|
397
|
-
widget_body = DictScan.replace_key_value(widget_body, "/AP", ap_dict)
|
|
398
|
-
end
|
|
58
|
+
if is_radio && group_id
|
|
59
|
+
AcroThat::Fields::Radio.new(@document, @name, @options.merge(metadata: @metadata))
|
|
60
|
+
elsif [:signature, "signature", "/Sig"].include?(type_input)
|
|
61
|
+
AcroThat::Fields::Signature.new(@document, @name, @options.merge(metadata: @metadata))
|
|
62
|
+
elsif [:checkbox, "checkbox"].include?(type_input)
|
|
63
|
+
AcroThat::Fields::Checkbox.new(@document, @name, @options.merge(metadata: @metadata))
|
|
399
64
|
else
|
|
400
|
-
#
|
|
401
|
-
|
|
65
|
+
# Default to text field
|
|
66
|
+
AcroThat::Fields::Text.new(@document, @name, @options.merge(metadata: @metadata))
|
|
402
67
|
end
|
|
403
|
-
|
|
404
|
-
# Set /AS based on the value - use the EXACT same normalization logic as widget creation
|
|
405
|
-
# This ensures consistency between /V and /AS
|
|
406
|
-
# Normalize value: "Yes" if truthy (Yes, "/Yes", true, etc.), otherwise "Off"
|
|
407
|
-
value_str = @field_value.to_s
|
|
408
|
-
is_checked = value_str == "Yes" || value_str == "/Yes" || value_str == "true" || @field_value == true
|
|
409
|
-
normalized_value = is_checked ? "Yes" : "Off"
|
|
410
|
-
|
|
411
|
-
# Set /AS to match normalized value (same as what was set for /V in widget creation)
|
|
412
|
-
as_value = if normalized_value == "Yes"
|
|
413
|
-
"/Yes"
|
|
414
|
-
else
|
|
415
|
-
"/Off"
|
|
416
|
-
end
|
|
417
|
-
|
|
418
|
-
widget_body = if widget_body.include?("/AS")
|
|
419
|
-
DictScan.replace_key_value(widget_body, "/AS", as_value)
|
|
420
|
-
else
|
|
421
|
-
DictScan.upsert_key_value(widget_body, "/AS", as_value)
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
apply_patch(widget_ref, widget_body, original_widget_body)
|
|
425
|
-
end
|
|
426
|
-
|
|
427
|
-
def create_checkbox_yes_appearance(width, height)
|
|
428
|
-
# Create a form XObject that draws a checked checkbox
|
|
429
|
-
# Box outline + checkmark
|
|
430
|
-
# Scale to match width and height
|
|
431
|
-
# Simple appearance: draw a box and a checkmark
|
|
432
|
-
# For simplicity, use PDF drawing operators
|
|
433
|
-
# Box: rectangle from (0,0) to (width, height)
|
|
434
|
-
# Checkmark: simple path drawing
|
|
435
|
-
|
|
436
|
-
# PDF content stream for checked checkbox
|
|
437
|
-
# Draw just the checkmark (no box border)
|
|
438
|
-
border_width = [width * 0.08, height * 0.08].min
|
|
439
|
-
|
|
440
|
-
# Calculate checkmark path
|
|
441
|
-
check_x1 = width * 0.25
|
|
442
|
-
check_y1 = height * 0.45
|
|
443
|
-
check_x2 = width * 0.45
|
|
444
|
-
check_y2 = height * 0.25
|
|
445
|
-
check_x3 = width * 0.75
|
|
446
|
-
check_y3 = height * 0.75
|
|
447
|
-
|
|
448
|
-
content_stream = "q\n"
|
|
449
|
-
content_stream += "0 0 0 rg\n" # Black color (darker)
|
|
450
|
-
content_stream += "#{border_width} w\n" # Line width
|
|
451
|
-
# Draw checkmark only (no box border)
|
|
452
|
-
content_stream += "#{check_x1} #{check_y1} m\n"
|
|
453
|
-
content_stream += "#{check_x2} #{check_y2} l\n"
|
|
454
|
-
content_stream += "#{check_x3} #{check_y3} l\n"
|
|
455
|
-
content_stream += "S\n" # Stroke
|
|
456
|
-
content_stream += "Q\n"
|
|
457
|
-
|
|
458
|
-
build_form_xobject(content_stream, width, height)
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
def create_checkbox_off_appearance(width, height)
|
|
462
|
-
# Create a form XObject for unchecked checkbox
|
|
463
|
-
# Empty appearance (no border, no checkmark) - viewer will draw default checkbox
|
|
464
|
-
|
|
465
|
-
content_stream = "q\n"
|
|
466
|
-
# Empty appearance for unchecked state
|
|
467
|
-
content_stream += "Q\n"
|
|
468
|
-
|
|
469
|
-
build_form_xobject(content_stream, width, height)
|
|
470
|
-
end
|
|
471
|
-
|
|
472
|
-
def build_form_xobject(content_stream, width, height)
|
|
473
|
-
# Build a Form XObject dictionary with the given content stream
|
|
474
|
-
dict = "<<\n"
|
|
475
|
-
dict += " /Type /XObject\n"
|
|
476
|
-
dict += " /Subtype /Form\n"
|
|
477
|
-
dict += " /BBox [0 0 #{width} #{height}]\n"
|
|
478
|
-
dict += " /Length #{content_stream.bytesize}\n"
|
|
479
|
-
dict += ">>\n"
|
|
480
|
-
dict += "stream\n"
|
|
481
|
-
dict += content_stream
|
|
482
|
-
dict += "\nendstream"
|
|
483
|
-
|
|
484
|
-
dict
|
|
485
68
|
end
|
|
486
69
|
end
|
|
487
70
|
end
|
|
@@ -48,9 +48,8 @@ module AcroThat
|
|
|
48
48
|
# Check if new_value looks like base64 image data or data URI
|
|
49
49
|
image_data = @new_value
|
|
50
50
|
if image_data && image_data.is_a?(String) && (image_data.start_with?("data:image/") || (image_data.length > 50 && image_data.match?(%r{^[A-Za-z0-9+/]*={0,2}$})))
|
|
51
|
-
# Try adding signature appearance
|
|
52
|
-
|
|
53
|
-
result = action.call
|
|
51
|
+
# Try adding signature appearance using Signature field class
|
|
52
|
+
result = AcroThat::Fields::Signature.add_appearance(@document, fld.ref, image_data)
|
|
54
53
|
return result if result
|
|
55
54
|
# If appearance fails, fall through to normal update
|
|
56
55
|
end
|
|
@@ -143,17 +142,18 @@ module AcroThat
|
|
|
143
142
|
end
|
|
144
143
|
end
|
|
145
144
|
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
if is_radio
|
|
146
|
+
# For radio buttons, update all widget appearances (overwrite existing)
|
|
147
|
+
update_radio_button_appearances(field_ref)
|
|
148
|
+
else
|
|
149
|
+
# For checkboxes, create/update appearance
|
|
148
150
|
widget_ref = find_checkbox_widget(fld.ref)
|
|
149
151
|
if widget_ref
|
|
150
152
|
widget_body = get_object_body_with_patch(widget_ref)
|
|
151
|
-
# Create appearances if /AP doesn't exist
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
add_checkbox_appearance(widget_ref, rect[:width], rect[:height])
|
|
156
|
-
end
|
|
153
|
+
# Create appearances if /AP doesn't exist, or overwrite if it does
|
|
154
|
+
rect = extract_widget_rect(widget_body)
|
|
155
|
+
if rect && rect[:width].positive? && rect[:height].positive?
|
|
156
|
+
add_checkbox_appearance(widget_ref, rect[:width], rect[:height])
|
|
157
157
|
end
|
|
158
158
|
end
|
|
159
159
|
end
|
|
@@ -338,9 +338,14 @@ module AcroThat
|
|
|
338
338
|
return unless af_ref
|
|
339
339
|
|
|
340
340
|
acro_body = get_object_body_with_patch(af_ref)
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
341
|
+
# Set /NeedAppearances false to use our custom appearance streams
|
|
342
|
+
# If we set it to true, viewers will ignore our custom appearances and generate defaults
|
|
343
|
+
# (e.g., circular radio buttons instead of our square checkboxes)
|
|
344
|
+
acro_patched = if acro_body.include?("/NeedAppearances")
|
|
345
|
+
DictScan.replace_key_value(acro_body, "/NeedAppearances", "false")
|
|
346
|
+
else
|
|
347
|
+
DictScan.upsert_key_value(acro_body, "/NeedAppearances", "false")
|
|
348
|
+
end
|
|
344
349
|
apply_patch(af_ref, acro_patched, acro_body)
|
|
345
350
|
end
|
|
346
351
|
|
|
@@ -396,6 +401,76 @@ module AcroThat
|
|
|
396
401
|
nil
|
|
397
402
|
end
|
|
398
403
|
|
|
404
|
+
def update_radio_button_appearances(parent_ref)
|
|
405
|
+
# Find all widgets that are children of this parent field
|
|
406
|
+
widgets = []
|
|
407
|
+
|
|
408
|
+
# Check patches first
|
|
409
|
+
patches = @document.instance_variable_get(:@patches)
|
|
410
|
+
patches.each do |patch|
|
|
411
|
+
next unless patch[:body]
|
|
412
|
+
next unless DictScan.is_widget?(patch[:body])
|
|
413
|
+
|
|
414
|
+
next unless patch[:body] =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R}
|
|
415
|
+
|
|
416
|
+
widget_parent_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
|
|
417
|
+
if widget_parent_ref == parent_ref
|
|
418
|
+
widgets << patch[:ref]
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Also check resolver (for existing widgets)
|
|
423
|
+
resolver.each_object do |ref, body|
|
|
424
|
+
next unless body && DictScan.is_widget?(body)
|
|
425
|
+
|
|
426
|
+
next unless body =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R}
|
|
427
|
+
|
|
428
|
+
widget_parent_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
|
|
429
|
+
if (widget_parent_ref == parent_ref) && !widgets.include?(ref)
|
|
430
|
+
widgets << ref
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Update appearance for each widget using Radio class method
|
|
435
|
+
widgets.each do |widget_ref|
|
|
436
|
+
widget_body = get_object_body_with_patch(widget_ref)
|
|
437
|
+
next unless widget_body
|
|
438
|
+
|
|
439
|
+
# Get widget dimensions
|
|
440
|
+
rect = extract_widget_rect(widget_body)
|
|
441
|
+
next unless rect && rect[:width].positive? && rect[:height].positive?
|
|
442
|
+
|
|
443
|
+
# Get export value from widget's /AP /N dictionary
|
|
444
|
+
export_value = nil
|
|
445
|
+
if widget_body.include?("/AP")
|
|
446
|
+
ap_tok = DictScan.value_token_after("/AP", widget_body)
|
|
447
|
+
if ap_tok && ap_tok.start_with?("<<")
|
|
448
|
+
n_tok = DictScan.value_token_after("/N", ap_tok)
|
|
449
|
+
if n_tok && n_tok.start_with?("<<")
|
|
450
|
+
# Extract export value (not /Off)
|
|
451
|
+
export_values = n_tok.scan(%r{/([^\s<>\[\]]+)\s+\d+\s+\d+\s+R}).flatten.reject { |v| v == "Off" }
|
|
452
|
+
export_value = export_values.first if export_values.any?
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# If no export value found, generate one
|
|
458
|
+
export_value ||= "widget_#{widget_ref[0]}"
|
|
459
|
+
|
|
460
|
+
# Create a Radio instance to reuse appearance creation logic
|
|
461
|
+
radio_handler = AcroThat::Fields::Radio.new(@document, "", { width: rect[:width], height: rect[:height] })
|
|
462
|
+
radio_handler.send(
|
|
463
|
+
:add_radio_button_appearance,
|
|
464
|
+
widget_ref[0],
|
|
465
|
+
export_value,
|
|
466
|
+
0, 0, # x, y not needed when overwriting
|
|
467
|
+
rect[:width],
|
|
468
|
+
rect[:height],
|
|
469
|
+
parent_ref
|
|
470
|
+
)
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
399
474
|
def extract_widget_rect(widget_body)
|
|
400
475
|
return nil unless widget_body
|
|
401
476
|
|