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.
@@ -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
@@ -28,6 +28,8 @@ module AcroThat
28
28
  @raw = extract_pdf_from_form_data(raw_bytes).freeze
29
29
  @resolver = AcroThat::ObjectResolver.new(@raw)
30
30
  @patches = []
31
+ # Track radio button groups: group_id -> parent_field_ref
32
+ @radio_groups = {}
31
33
  end
32
34
 
33
35
  # Flatten this document to remove incremental updates
@@ -35,18 +37,27 @@ module AcroThat
35
37
  root_ref = @resolver.root_ref
36
38
  raise "Cannot flatten: no /Root found" unless root_ref
37
39
 
38
- 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
39
44
  @resolver.each_object do |ref, body|
40
- 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
41
49
  end
42
50
 
43
- objects.sort_by! { |obj| obj[:ref][0] }
51
+ # Sort references by object number
52
+ refs.sort_by! { |ref| ref[0] }
44
53
 
54
+ # Second pass: write objects in sorted order, retrieving bodies on demand
45
55
  writer = PDFWriter.new
46
56
  writer.write_header
47
57
 
48
- objects.each do |obj|
49
- 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
50
61
  end
51
62
 
52
63
  writer.write_xref
@@ -58,7 +69,6 @@ module AcroThat
58
69
  end
59
70
 
60
71
  # Write trailer
61
- max_obj_num = objects.map { |obj| obj[:ref][0] }.max || 0
62
72
  writer.write_trailer(max_obj_num + 1, root_ref, info_ref)
63
73
 
64
74
  writer.output
@@ -381,9 +391,11 @@ module AcroThat
381
391
  all_fields = list_fields
382
392
 
383
393
  if block_given?
384
- # 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
385
397
  all_fields.each do |field|
386
- fields_to_remove.add(field.name) unless yield(field.name)
398
+ fields_to_remove.add(field.name) if yield(field)
387
399
  end
388
400
  elsif keep_fields
389
401
  # Keep only specified fields
@@ -443,19 +455,28 @@ module AcroThat
443
455
  end
444
456
  end
445
457
 
446
- # Collect objects to write (excluding removed fields and widgets)
447
- 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 = []
448
461
  @resolver.each_object do |ref, body|
449
462
  next if field_refs_to_remove.include?(ref)
450
463
  next if widget_refs_to_remove.include?(ref)
451
464
  next unless body
452
465
 
453
- 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
454
475
  end
455
476
 
456
477
  # Process AcroForm to remove field references from /Fields array
457
478
  af_ref = acroform_ref
458
- if af_ref
479
+ if af_ref && refs_to_keep.include?(af_ref)
459
480
  # Find the AcroForm object in our objects list
460
481
  af_obj = objects.find { |o| o[:ref] == af_ref }
461
482
  if af_obj
@@ -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