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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1 -4
- 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 +33 -448
- data/lib/acro_that/actions/update_field.rb +168 -38
- data/lib/acro_that/dict_scan.rb +26 -0
- data/lib/acro_that/document.rb +33 -12
- data/lib/acro_that/fields/base.rb +371 -0
- data/lib/acro_that/fields/checkbox.rb +164 -0
- data/lib/acro_that/fields/radio.rb +220 -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,61 @@ 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
|
-
|
|
70
|
-
|
|
71
|
-
# Add widget to the target page's /Annots
|
|
72
|
-
add_widget_to_page(widget_obj_num, page_num)
|
|
73
|
-
|
|
74
|
-
# If this is a signature field with image data, add the signature appearance
|
|
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
|
|
32
|
+
# Call the field handler
|
|
33
|
+
field_handler.call
|
|
97
34
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
45
|
+
def normalize_hash_keys(hash)
|
|
46
|
+
return hash unless hash.is_a?(Hash)
|
|
138
47
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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"
|
|
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
|
|
148
51
|
end
|
|
149
|
-
|
|
150
|
-
dict += ">>"
|
|
151
|
-
dict
|
|
152
52
|
end
|
|
153
53
|
|
|
154
|
-
def
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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])
|
|
54
|
+
def create_field_handler(type_input)
|
|
55
|
+
is_radio = [:radio, "radio"].include?(type_input)
|
|
56
|
+
group_id = @options[:group_id]
|
|
57
|
+
is_button = [:button, "button", "/Btn", "/btn"].include?(type_input)
|
|
58
|
+
|
|
59
|
+
if is_radio && group_id
|
|
60
|
+
AcroThat::Fields::Radio.new(@document, @name, @options.merge(metadata: @metadata))
|
|
61
|
+
elsif [:signature, "signature", "/Sig"].include?(type_input)
|
|
62
|
+
AcroThat::Fields::Signature.new(@document, @name, @options.merge(metadata: @metadata))
|
|
63
|
+
elsif [:checkbox, "checkbox"].include?(type_input) || is_button
|
|
64
|
+
# :button type maps to /Btn which are checkboxes by default (unless radio flag is set)
|
|
65
|
+
AcroThat::Fields::Checkbox.new(@document, @name, @options.merge(metadata: @metadata))
|
|
227
66
|
else
|
|
228
|
-
#
|
|
229
|
-
|
|
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
|
|
236
|
-
|
|
237
|
-
# Step 2.5: Remove /XFA if present (prevents XFA detection warnings in viewers like Master PDF)
|
|
238
|
-
# We're creating AcroForms, not XFA forms, so remove /XFA if it exists
|
|
239
|
-
if patched.include?("/XFA")
|
|
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
|
|
67
|
+
# Default to text field
|
|
68
|
+
AcroThat::Fields::Text.new(@document, @name, @options.merge(metadata: @metadata))
|
|
270
69
|
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
|
-
end
|
|
306
|
-
|
|
307
|
-
def add_widget_to_page(widget_obj_num, page_num)
|
|
308
|
-
# Find the specific page using the same logic as find_page_ref
|
|
309
|
-
target_page_ref = find_page_ref(page_num)
|
|
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 }
|
|
383
|
-
|
|
384
|
-
# Get current widget body and add /AP dictionary
|
|
385
|
-
widget_ref = [widget_obj_num, 0]
|
|
386
|
-
original_widget_body = get_object_body_with_patch(widget_ref)
|
|
387
|
-
widget_body = original_widget_body.dup
|
|
388
|
-
|
|
389
|
-
# Create /AP dictionary with Yes and Off appearances
|
|
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
|
|
399
|
-
else
|
|
400
|
-
# Insert /AP before closing >>
|
|
401
|
-
widget_body = DictScan.upsert_key_value(widget_body, "/AP", ap_dict)
|
|
402
|
-
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
70
|
end
|
|
486
71
|
end
|
|
487
72
|
end
|