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.
@@ -130,6 +130,32 @@ module AcroThat
130
130
  end
131
131
  end
132
132
 
133
+ # Encode a string as a PDF name, escaping special characters with hex encoding
134
+ # PDF names must escape: # ( ) < > [ ] { } / % and control characters
135
+ # Example: "(Two Hr) Priority 2" becomes "/#28Two Hr#29 Priority 2"
136
+ def encode_pdf_name(name)
137
+ name_str = name.to_s
138
+ # Remove leading / if present (we'll add it back)
139
+ name_str = name_str[1..] if name_str.start_with?("/")
140
+
141
+ # Encode special characters as hex
142
+ encoded = name_str.each_byte.map do |byte|
143
+ char = byte.chr
144
+ # PDF name special characters that need hex encoding: # ( ) < > [ ] { } / %
145
+ # Also encode control characters (0x00-0x1F, 0x7F) and non-ASCII (0x80-0xFF)
146
+ if ["#", "(", ")", "<", ">", "[", "]", "{", "}", "/", "%"].include?(char) ||
147
+ byte.between?(0x00, 0x1F) || byte == 0x7F || byte.between?(0x80, 0xFF)
148
+ # Hex encode: # followed by 2-digit hex
149
+ "##{byte.to_s(16).upcase.rjust(2, '0')}"
150
+ else
151
+ # Regular printable ASCII: use as-is
152
+ char
153
+ end
154
+ end.join
155
+
156
+ "/#{encoded}"
157
+ end
158
+
133
159
  # Format a metadata key as a PDF dictionary key (ensure it starts with /)
134
160
  def format_pdf_key(key)
135
161
  key_str = key.to_s
@@ -18,13 +18,18 @@ module AcroThat
18
18
 
19
19
  def initialize(path_or_io)
20
20
  @path = path_or_io.is_a?(String) ? path_or_io : nil
21
- @raw = case path_or_io
22
- when String then File.binread(path_or_io)
23
- else path_or_io.binmode
24
- path_or_io.read
25
- end.freeze
21
+ raw_bytes = case path_or_io
22
+ when String then File.binread(path_or_io)
23
+ else path_or_io.binmode
24
+ path_or_io.read
25
+ end
26
+
27
+ # Extract PDF content if wrapped in multipart form data
28
+ @raw = extract_pdf_from_form_data(raw_bytes).freeze
26
29
  @resolver = AcroThat::ObjectResolver.new(@raw)
27
30
  @patches = []
31
+ # Track radio button groups: group_id -> parent_field_ref
32
+ @radio_groups = {}
28
33
  end
29
34
 
30
35
  # Flatten this document to remove incremental updates
@@ -32,18 +37,27 @@ module AcroThat
32
37
  root_ref = @resolver.root_ref
33
38
  raise "Cannot flatten: no /Root found" unless root_ref
34
39
 
35
- objects = []
40
+ # First pass: collect only references (lightweight) and find max_obj_num
41
+ # This avoids loading all object bodies into memory at once
42
+ refs = []
43
+ max_obj_num = 0
36
44
  @resolver.each_object do |ref, body|
37
- objects << { ref: ref, body: body } if body
45
+ if body
46
+ refs << ref
47
+ max_obj_num = [max_obj_num, ref[0]].max
48
+ end
38
49
  end
39
50
 
40
- objects.sort_by! { |obj| obj[:ref][0] }
51
+ # Sort references by object number
52
+ refs.sort_by! { |ref| ref[0] }
41
53
 
54
+ # Second pass: write objects in sorted order, retrieving bodies on demand
42
55
  writer = PDFWriter.new
43
56
  writer.write_header
44
57
 
45
- objects.each do |obj|
46
- writer.write_object(obj[:ref], obj[:body])
58
+ refs.each do |ref|
59
+ body = @resolver.object_body(ref)
60
+ writer.write_object(ref, body) if body
47
61
  end
48
62
 
49
63
  writer.write_xref
@@ -55,7 +69,6 @@ module AcroThat
55
69
  end
56
70
 
57
71
  # Write trailer
58
- max_obj_num = objects.map { |obj| obj[:ref][0] }.max || 0
59
72
  writer.write_trailer(max_obj_num + 1, root_ref, info_ref)
60
73
 
61
74
  writer.output
@@ -378,9 +391,11 @@ module AcroThat
378
391
  all_fields = list_fields
379
392
 
380
393
  if block_given?
381
- # Use block to determine which fields to keep
394
+ # Use block to determine which fields to remove
395
+ # Block receives field object (can check field.name, field.value, etc.)
396
+ # Return true to remove the field, false to keep it
382
397
  all_fields.each do |field|
383
- fields_to_remove.add(field.name) unless yield(field.name)
398
+ fields_to_remove.add(field.name) if yield(field)
384
399
  end
385
400
  elsif keep_fields
386
401
  # Keep only specified fields
@@ -440,19 +455,28 @@ module AcroThat
440
455
  end
441
456
  end
442
457
 
443
- # Collect objects to write (excluding removed fields and widgets)
444
- objects = []
458
+ # Collect refs to write (excluding removed fields and widgets)
459
+ # Store refs only initially to avoid loading all bodies into memory at once
460
+ refs_to_keep = []
445
461
  @resolver.each_object do |ref, body|
446
462
  next if field_refs_to_remove.include?(ref)
447
463
  next if widget_refs_to_remove.include?(ref)
448
464
  next unless body
449
465
 
450
- objects << { ref: ref, body: body }
466
+ refs_to_keep << ref
467
+ end
468
+
469
+ # Build objects hash - load bodies only for objects we need to modify
470
+ # For unmodified objects, we'll load bodies on demand during writing
471
+ objects = []
472
+ refs_to_keep.each do |ref|
473
+ body = @resolver.object_body(ref)
474
+ objects << { ref: ref, body: body } if body
451
475
  end
452
476
 
453
477
  # Process AcroForm to remove field references from /Fields array
454
478
  af_ref = acroform_ref
455
- if af_ref
479
+ if af_ref && refs_to_keep.include?(af_ref)
456
480
  # Find the AcroForm object in our objects list
457
481
  af_obj = objects.find { |o| o[:ref] == af_ref }
458
482
  if af_obj
@@ -634,6 +658,28 @@ module AcroThat
634
658
 
635
659
  private
636
660
 
661
+ # Extract PDF content from multipart form data if present
662
+ # Some PDFs are uploaded as multipart form data with boundary markers
663
+ def extract_pdf_from_form_data(bytes)
664
+ # Check if this looks like multipart form data
665
+ if bytes =~ /\A------\w+/
666
+ # Find the PDF header
667
+ pdf_start = bytes.index("%PDF")
668
+ return bytes unless pdf_start
669
+
670
+ # Extract PDF content from start to EOF
671
+ pdf_end = bytes.rindex("%%EOF")
672
+ return bytes unless pdf_end
673
+
674
+ # Extract just the PDF portion
675
+ pdf_content = bytes[pdf_start..(pdf_end + 4)]
676
+ return pdf_content
677
+ end
678
+
679
+ # Not form data, return as-is
680
+ bytes
681
+ end
682
+
637
683
  def collect_pages_from_tree(pages_ref, page_objects)
638
684
  pages_body = @resolver.object_body(pages_ref)
639
685
  return unless pages_body
@@ -0,0 +1,365 @@
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 other fields, encode as PDF string
111
+ if should_set_value && normalized_field_value && !normalized_field_value.to_s.empty?
112
+ # For radio buttons, only set /V if selected option is explicitly set to true
113
+ if is_radio_field
114
+ # Only set /V for radio buttons if selected option is true
115
+ if [true, "true"].include?(@options[:selected]) && normalized_field_value.to_s.start_with?("/")
116
+ dict += " /V #{normalized_field_value}\n"
117
+ end
118
+ else
119
+ dict += " /V #{DictScan.encode_pdf_string(normalized_field_value)}\n"
120
+ end
121
+ end
122
+
123
+ # Apply other metadata entries (excluding Ff which we handled above)
124
+ @metadata.each do |key, val|
125
+ next if key == :Ff
126
+
127
+ pdf_key = DictScan.format_pdf_key(key)
128
+ pdf_value = DictScan.format_pdf_value(val)
129
+ dict += " #{pdf_key} #{pdf_value}\n"
130
+ end
131
+
132
+ dict += ">>"
133
+ dict
134
+ end
135
+
136
+ def create_widget_annotation_with_parent(_widget_obj_num, parent_ref, page_ref, x, y, width, height, type, value,
137
+ is_radio: false)
138
+ rect_array = "[#{x} #{y} #{x + width} #{y + height}]"
139
+ widget = "<<\n"
140
+ widget += " /Type /Annot\n"
141
+ widget += " /Subtype /Widget\n"
142
+ widget += " /Parent #{parent_ref[0]} #{parent_ref[1]} R\n"
143
+ widget += " /P #{page_ref[0]} #{page_ref[1]} R\n" if page_ref
144
+
145
+ widget += " /FT #{type}\n"
146
+ if is_radio
147
+ widget += " /T #{DictScan.encode_pdf_string(@name)}\n"
148
+ else
149
+
150
+ should_set_value = if type == "/Sig" && value && !value.empty?
151
+ !(value.is_a?(String) && (value.start_with?("data:image/") || (value.length > 50 && value.match?(%r{^[A-Za-z0-9+/]*={0,2}$}))))
152
+ elsif type == "/Btn"
153
+ true
154
+ else
155
+ true
156
+ end
157
+
158
+ if type == "/Btn" && should_set_value
159
+ value_str = value.to_s
160
+ is_checked = ["Yes", "/Yes", "true"].include?(value_str) || value == true
161
+ checkbox_value = is_checked ? "Yes" : "Off"
162
+ widget += " /V #{DictScan.encode_pdf_string(checkbox_value)}\n"
163
+ elsif should_set_value && value && !value.empty?
164
+ widget += " /V #{DictScan.encode_pdf_string(value)}\n"
165
+ end
166
+ end
167
+
168
+ widget += " /Rect #{rect_array}\n"
169
+ widget += " /F 4\n"
170
+
171
+ widget += if is_radio
172
+ " /MK << /BC [0.0] /BG [1.0] >>\n"
173
+ else
174
+ " /DA (/Helv 0 Tf 0 g)\n"
175
+ end
176
+
177
+ @metadata.each do |key, val|
178
+ pdf_key = DictScan.format_pdf_key(key)
179
+ pdf_value = DictScan.format_pdf_value(val)
180
+ next if ["/F", "/V"].include?(pdf_key)
181
+ next if is_radio && ["/Ff", "/DA"].include?(pdf_key)
182
+
183
+ widget += " #{pdf_key} #{pdf_value}\n"
184
+ end
185
+
186
+ widget += ">>"
187
+ widget
188
+ end
189
+
190
+ def add_field_to_acroform_with_defaults(field_obj_num)
191
+ af_ref = acroform_ref
192
+ return false unless af_ref
193
+
194
+ af_body = get_object_body_with_patch(af_ref)
195
+ # Use +"" instead of dup to create a mutable copy without keeping reference to original
196
+ patched = af_body + ""
197
+
198
+ # Step 1: Add field to /Fields array
199
+ fields_array_ref = DictScan.value_token_after("/Fields", patched)
200
+
201
+ if fields_array_ref && fields_array_ref =~ /\A(\d+)\s+(\d+)\s+R/
202
+ arr_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
203
+ arr_body = get_object_body_with_patch(arr_ref)
204
+ new_body = DictScan.add_ref_to_array(arr_body, [field_obj_num, 0])
205
+ apply_patch(arr_ref, new_body, arr_body)
206
+ elsif patched.include?("/Fields")
207
+ patched = DictScan.add_ref_to_inline_array(patched, "/Fields", [field_obj_num, 0])
208
+ else
209
+ patched = DictScan.upsert_key_value(patched, "/Fields", "[#{field_obj_num} 0 R]")
210
+ end
211
+
212
+ # Step 2: Ensure /NeedAppearances false (we provide custom appearance streams)
213
+ # Setting to false tells viewers to use our custom appearances instead of generating defaults
214
+ # If we don't set this or set it to true, viewers will ignore our custom appearances and
215
+ # generate their own default appearances (e.g., circular radio buttons instead of our squares)
216
+ patched = if patched.include?("/NeedAppearances")
217
+ # Replace existing /NeedAppearances with false
218
+ DictScan.replace_key_value(patched, "/NeedAppearances", "false")
219
+ else
220
+ DictScan.upsert_key_value(patched, "/NeedAppearances", "false")
221
+ end
222
+
223
+ # Step 2.5: Remove /XFA if present
224
+ if patched.include?("/XFA")
225
+ xfa_pattern = %r{/XFA(?=[\s(<\[/])}
226
+ if patched.match(xfa_pattern)
227
+ xfa_value = DictScan.value_token_after("/XFA", patched)
228
+ if xfa_value
229
+ xfa_match = patched.match(xfa_pattern)
230
+ if xfa_match
231
+ key_start = xfa_match.begin(0)
232
+ value_start = xfa_match.end(0)
233
+ value_start += 1 while value_start < patched.length && patched[value_start] =~ /\s/
234
+ value_end = value_start + xfa_value.length
235
+ value_end += 1 while value_end < patched.length && patched[value_end] =~ /\s/
236
+ before = patched[0...key_start]
237
+ before = before.rstrip
238
+ after = patched[value_end..]
239
+ patched = "#{before} #{after.lstrip}".strip
240
+ patched = patched.gsub(/\s+/, " ")
241
+ end
242
+ end
243
+ end
244
+ end
245
+
246
+ # Step 3: Ensure /DR /Font has /Helv mapping
247
+ unless patched.include?("/DR") && patched.include?("/Helv")
248
+ font_obj_num = next_fresh_object_number
249
+ font_body = "<<\n /Type /Font\n /Subtype /Type1\n /BaseFont /Helvetica\n>>"
250
+ document.instance_variable_get(:@patches) << { ref: [font_obj_num, 0], body: font_body }
251
+
252
+ if patched.include?("/DR")
253
+ dr_tok = DictScan.value_token_after("/DR", patched)
254
+ if dr_tok && dr_tok.start_with?("<<")
255
+ unless dr_tok.include?("/Font")
256
+ new_dr_tok = dr_tok.chomp(">>") + " /Font << /Helv #{font_obj_num} 0 R >>\n>>"
257
+ patched = patched.sub(dr_tok) { |_| new_dr_tok }
258
+ end
259
+ else
260
+ patched = DictScan.replace_key_value(patched, "/DR", "<< /Font << /Helv #{font_obj_num} 0 R >> >>")
261
+ end
262
+ else
263
+ patched = DictScan.upsert_key_value(patched, "/DR", "<< /Font << /Helv #{font_obj_num} 0 R >> >>")
264
+ end
265
+ end
266
+
267
+ apply_patch(af_ref, patched, af_body)
268
+ true
269
+ end
270
+
271
+ def find_page_ref(page_num)
272
+ find_page_by_number(page_num)
273
+ end
274
+
275
+ def add_widget_to_page(widget_obj_num, page_num)
276
+ target_page_ref = find_page_ref(page_num)
277
+ return false unless target_page_ref
278
+
279
+ page_body = get_object_body_with_patch(target_page_ref)
280
+
281
+ new_body = if page_body =~ %r{/Annots\s*\[(.*?)\]}m
282
+ result = DictScan.add_ref_to_inline_array(page_body, "/Annots", [widget_obj_num, 0])
283
+ if result && result != page_body
284
+ result
285
+ else
286
+ annots_array = ::Regexp.last_match(1)
287
+ ref_token = "#{widget_obj_num} 0 R"
288
+ new_annots = if annots_array.strip.empty?
289
+ "[#{ref_token}]"
290
+ else
291
+ "[#{annots_array} #{ref_token}]"
292
+ end
293
+ page_body.sub(%r{/Annots\s*\[.*?\]}, "/Annots #{new_annots}")
294
+ end
295
+ elsif page_body =~ %r{/Annots\s+(\d+)\s+(\d+)\s+R}
296
+ annots_array_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
297
+ annots_array_body = get_object_body_with_patch(annots_array_ref)
298
+
299
+ ref_token = "#{widget_obj_num} 0 R"
300
+ if annots_array_body
301
+ new_annots_body = if annots_array_body.strip == "[]"
302
+ "[#{ref_token}]"
303
+ elsif annots_array_body.strip.start_with?("[") && annots_array_body.strip.end_with?("]")
304
+ without_brackets = annots_array_body.strip[1..-2].strip
305
+ "[#{without_brackets} #{ref_token}]"
306
+ else
307
+ "[#{annots_array_body} #{ref_token}]"
308
+ end
309
+
310
+ apply_patch(annots_array_ref, new_annots_body, annots_array_body)
311
+ page_body
312
+ else
313
+ page_body.sub(%r{/Annots\s+\d+\s+\d+\s+R}, "/Annots [#{ref_token}]")
314
+ end
315
+ else
316
+ ref_token = "#{widget_obj_num} 0 R"
317
+ if page_body.include?(">>")
318
+ page_body.reverse.sub(">>".reverse, "/Annots [#{ref_token}]>>".reverse).reverse
319
+ else
320
+ page_body + " /Annots [#{ref_token}]"
321
+ end
322
+ end
323
+
324
+ apply_patch(target_page_ref, new_body, page_body) if new_body && new_body != page_body
325
+ true
326
+ end
327
+
328
+ def add_widget_to_parent_kids(parent_ref, widget_obj_num)
329
+ parent_body = get_object_body_with_patch(parent_ref)
330
+ return unless parent_body
331
+
332
+ kids_array_ref = DictScan.value_token_after("/Kids", parent_body)
333
+
334
+ if kids_array_ref && kids_array_ref =~ /\A(\d+)\s+(\d+)\s+R\z/
335
+ arr_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
336
+ arr_body = get_object_body_with_patch(arr_ref)
337
+ new_body = DictScan.add_ref_to_array(arr_body, [widget_obj_num, 0])
338
+ apply_patch(arr_ref, new_body, arr_body)
339
+ elsif kids_array_ref && kids_array_ref.start_with?("[")
340
+ new_body = DictScan.add_ref_to_inline_array(parent_body, "/Kids", [widget_obj_num, 0])
341
+ apply_patch(parent_ref, new_body, parent_body) if new_body && new_body != parent_body
342
+ else
343
+ new_body = DictScan.upsert_key_value(parent_body, "/Kids", "[#{widget_obj_num} 0 R]")
344
+ apply_patch(parent_ref, new_body, parent_body) if new_body && new_body != parent_body
345
+ end
346
+ end
347
+
348
+ def build_form_xobject(content_stream, width, height)
349
+ dict = "<<\n"
350
+ dict += " /Type /XObject\n"
351
+ dict += " /Subtype /Form\n"
352
+ dict += " /BBox [0 0 #{width} #{height}]\n"
353
+ dict += " /Matrix [1 0 0 1 0 0]\n"
354
+ dict += " /Resources << >>\n"
355
+ dict += " /Length #{content_stream.bytesize}\n"
356
+ dict += ">>\n"
357
+ dict += "stream\n"
358
+ dict += content_stream
359
+ dict += "\nendstream"
360
+
361
+ dict
362
+ end
363
+ end
364
+ end
365
+ end
@@ -0,0 +1,131 @@
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 + ""
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
+ as_value = if normalized_value == "Yes"
62
+ "/Yes"
63
+ else
64
+ "/Off"
65
+ end
66
+
67
+ widget_body = if widget_body.include?("/AS")
68
+ DictScan.replace_key_value(widget_body, "/AS", as_value)
69
+ else
70
+ DictScan.upsert_key_value(widget_body, "/AS", as_value)
71
+ end
72
+
73
+ apply_patch(widget_ref, widget_body, original_widget_body)
74
+ end
75
+
76
+ def create_checkbox_yes_appearance(width, height)
77
+ border_width = [width * 0.08, height * 0.08].min
78
+ line_width = [width * 0.05, height * 0.05].min
79
+
80
+ check_x1 = width * 0.25
81
+ check_y1 = height * 0.45
82
+ check_x2 = width * 0.45
83
+ check_y2 = height * 0.25
84
+ check_x3 = width * 0.75
85
+ check_y3 = height * 0.75
86
+
87
+ content_stream = "q\n"
88
+ # Draw square border around field bounds
89
+ content_stream += "0 0 0 RG\n" # Black stroke color
90
+ content_stream += "#{line_width} w\n" # Line width
91
+ # Draw rectangle from (0,0) to (width, height)
92
+ content_stream += "0 0 m\n"
93
+ content_stream += "#{width} 0 l\n"
94
+ content_stream += "#{width} #{height} l\n"
95
+ content_stream += "0 #{height} l\n"
96
+ content_stream += "0 0 l\n"
97
+ content_stream += "S\n" # Stroke the border
98
+
99
+ # Draw checkmark
100
+ content_stream += "0 0 0 rg\n" # Black fill color
101
+ content_stream += "#{border_width} w\n" # Line width for checkmark
102
+ content_stream += "#{check_x1} #{check_y1} m\n"
103
+ content_stream += "#{check_x2} #{check_y2} l\n"
104
+ content_stream += "#{check_x3} #{check_y3} l\n"
105
+ content_stream += "S\n" # Stroke the checkmark
106
+ content_stream += "Q\n"
107
+
108
+ build_form_xobject(content_stream, width, height)
109
+ end
110
+
111
+ def create_checkbox_off_appearance(width, height)
112
+ line_width = [width * 0.05, height * 0.05].min
113
+
114
+ content_stream = "q\n"
115
+ # Draw square border around field bounds
116
+ content_stream += "0 0 0 RG\n" # Black stroke color
117
+ content_stream += "#{line_width} w\n" # Line width
118
+ # Draw rectangle from (0,0) to (width, height)
119
+ content_stream += "0 0 m\n"
120
+ content_stream += "#{width} 0 l\n"
121
+ content_stream += "#{width} #{height} l\n"
122
+ content_stream += "0 #{height} l\n"
123
+ content_stream += "0 0 l\n"
124
+ content_stream += "S\n" # Stroke the border
125
+ content_stream += "Q\n"
126
+
127
+ build_form_xobject(content_stream, width, height)
128
+ end
129
+ end
130
+ end
131
+ end