acro_that 0.1.8 → 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.
@@ -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
- @field_type = case type_input
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
- # Radio button flags: Radio (bit 15 = 32768) + NoToggleToOff (bit 14 = 16384) = 49152
47
- if [:radio, "radio"].include?(type_input) && !(@metadata[:Ff] || @metadata["Ff"])
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
- # Create a proper field dictionary + a widget annotation that references it via /Parent
52
- @field_obj_num = next_fresh_object_number
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
- # Add field reference (not widget) to AcroForm /Fields AND ensure defaults in ONE patch
69
- add_field_to_acroform_with_defaults(@field_obj_num)
32
+ # Call the field handler
33
+ field_handler.call
70
34
 
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
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 create_field_dictionary(value, type)
108
- dict = "<<\n"
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
- # 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
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 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 }
54
+ def create_field_handler(type_input)
55
+ is_radio = [:radio, "radio"].include?(type_input)
56
+ group_id = @options[:group_id]
383
57
 
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
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
- # Insert /AP before closing >>
401
- widget_body = DictScan.upsert_key_value(widget_body, "/AP", ap_dict)
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
- action = Actions::AddSignatureAppearance.new(@document, fld.ref, image_data)
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
- # Only create checkbox appearances (not radio buttons)
147
- unless is_radio
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
- unless widget_body&.include?("/AP")
153
- rect = extract_widget_rect(widget_body)
154
- if rect && rect[:width].positive? && rect[:height].positive?
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
- return if acro_body.include?("/NeedAppearances")
342
-
343
- acro_patched = DictScan.upsert_key_value(acro_body, "/NeedAppearances", "true")
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