corp_pdf 1.0.5

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.
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CorpPdf
4
+ # Represents a PDF form field
5
+ class Field
6
+ attr_accessor :value
7
+ attr_reader :name, :type, :ref, :x, :y, :width, :height, :page
8
+
9
+ TYPES = {
10
+ text: "/Tx",
11
+ button: "/Btn",
12
+ checkbox: "/Btn",
13
+ radio: "/Btn",
14
+ choice: "/Ch",
15
+ signature: "/Sig"
16
+ }.freeze
17
+
18
+ # Reverse lookup: map type strings to symbol keys
19
+ TYPE_KEYS = TYPES.invert.freeze
20
+
21
+ def initialize(name, value, type, ref, document = nil, position = {})
22
+ @name = name
23
+ @value = value
24
+ # Normalize type: accept symbol keys or type strings, default to "/Tx"
25
+ normalized_type = if type.is_a?(Symbol)
26
+ TYPES[type] || "/Tx"
27
+ else
28
+ type.to_s.strip
29
+ end
30
+ @type = normalized_type.empty? ? "/Tx" : normalized_type
31
+ @ref = ref
32
+ @document = document
33
+ @x = position[:x]
34
+ @y = position[:y]
35
+ @width = position[:width]
36
+ @height = position[:height]
37
+ @page = position[:page]
38
+ end
39
+
40
+ # Check if this is a text field
41
+ def text_field?
42
+ type == "/Tx"
43
+ end
44
+
45
+ # Check if this is a button field (checkbox/radio)
46
+ def button_field?
47
+ type == "/Btn"
48
+ end
49
+
50
+ # Check if this is a choice field (dropdown/list)
51
+ def choice_field?
52
+ type == "/Ch"
53
+ end
54
+
55
+ # Check if this is a signature field
56
+ def signature_field?
57
+ type == "/Sig"
58
+ end
59
+
60
+ # Check if the field has a value
61
+ def has_value?
62
+ !value.nil? && !value.to_s.empty?
63
+ end
64
+
65
+ # Get the object number (first element of ref)
66
+ def object_number
67
+ ref[0]
68
+ end
69
+
70
+ # Get the generation number (second element of ref)
71
+ def generation
72
+ ref[1]
73
+ end
74
+
75
+ # Check if field reference is valid (not [-1, 0] placeholder)
76
+ def valid_ref?
77
+ ref != [-1, 0]
78
+ end
79
+
80
+ # Equality comparison
81
+ def ==(other)
82
+ return false unless other.is_a?(Field)
83
+
84
+ name == other.name &&
85
+ value == other.value &&
86
+ type == other.type &&
87
+ ref == other.ref
88
+ end
89
+
90
+ # String representation for debugging
91
+ def to_s
92
+ type_str = type.inspect
93
+ type_str += " (:#{type_key})" if type_key
94
+ pos_str = if x && y && width && height
95
+ " x=#{x} y=#{y} w=#{width} h=#{height}"
96
+ else
97
+ " position=(unknown)"
98
+ end
99
+ page_str = page ? " page=#{page}" : ""
100
+ "#<CorpPdf::Field name=#{name.inspect} type=#{type_str} value=#{value.inspect} ref=#{ref.inspect}#{pos_str}#{page_str}>"
101
+ end
102
+
103
+ alias inspect to_s
104
+
105
+ # Check if position is known
106
+ def has_position?
107
+ !x.nil? && !y.nil? && !width.nil? && !height.nil?
108
+ end
109
+
110
+ # Get the symbol key for the field type (e.g., :text for "/Tx")
111
+ # Returns nil if the type is not in the TYPES mapping
112
+ def type_key
113
+ TYPE_KEYS[type]
114
+ end
115
+
116
+ # Update this field's value and optionally rename it in the document
117
+ # Returns true if the field was found and queued for write.
118
+ def update(new_value, new_name: nil)
119
+ return false unless @document
120
+ return false unless valid_ref?
121
+
122
+ action = Actions::UpdateField.new(@document, @name, new_value, new_name: new_name)
123
+ result = action.call
124
+
125
+ # Update the local value if update was successful
126
+ @value = new_value if result
127
+ # Update the local name if rename was successful
128
+ @name = new_name if result && new_name && !new_name.empty?
129
+
130
+ result
131
+ end
132
+
133
+ # Remove this field from the AcroForm /Fields array and mark the field object as deleted.
134
+ # Note: This does not purge page /Annots widgets (non-trivial); most viewers will hide the field
135
+ # once it is no longer in the field tree.
136
+ # Returns true if the field was removed.
137
+ def remove
138
+ return false unless @document
139
+ return false unless valid_ref?
140
+
141
+ action = Actions::RemoveField.new(@document, self)
142
+ action.call
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,384 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CorpPdf
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
+ # Apply /DA from metadata for text fields only, or use default
81
+ if type == "/Tx" && @metadata[:DA]
82
+ da_value = DictScan.format_pdf_value(@metadata[:DA])
83
+ dict += " /DA #{da_value}\n"
84
+ else
85
+ dict += " /DA (/Helv 0 Tf 0 g)\n"
86
+ end
87
+
88
+ # Check if this is a radio button (has Radio flag set)
89
+ is_radio_field = field_flags.anybits?(32_768)
90
+
91
+ # For signature fields with image data, don't set /V (appearance stream will be added separately)
92
+ # For radio buttons, /V should be the export value name (e.g., "/email", "/phone")
93
+ # For checkboxes, set /V to normalized value (Yes/Off)
94
+ # For other fields, set /V normally
95
+ should_set_value = if type == "/Sig" && value && !value.empty?
96
+ !(value.is_a?(String) && (value.start_with?("data:image/") || (value.length > 50 && value.match?(%r{^[A-Za-z0-9+/]*={0,2}$}))))
97
+ else
98
+ true
99
+ end
100
+
101
+ # For radio buttons: use export value as PDF name (e.g., "/email")
102
+ # For checkboxes: normalize to "Yes" or "Off"
103
+ # For other fields: use value as-is
104
+ normalized_field_value = if is_radio_field && value && !value.to_s.empty?
105
+ # Encode export value as PDF name (escapes special characters like parentheses)
106
+ DictScan.encode_pdf_name(value)
107
+ elsif type == "/Btn" && value
108
+ value_str = value.to_s
109
+ is_checked = ["Yes", "/Yes", "true"].include?(value_str) || value == true
110
+ is_checked ? "Yes" : "Off"
111
+ else
112
+ value
113
+ end
114
+
115
+ # For radio buttons, /V should only be set if explicitly selected
116
+ # For checkboxes, /V should be a PDF name to match /AS format
117
+ # For other fields, encode as PDF string
118
+ if should_set_value && normalized_field_value && !normalized_field_value.to_s.empty?
119
+ # For radio buttons, only set /V if selected option is explicitly set to true
120
+ if is_radio_field
121
+ # Only set /V for radio buttons if selected option is true
122
+ if [true, "true"].include?(@options[:selected]) && normalized_field_value.to_s.start_with?("/")
123
+ dict += " /V #{normalized_field_value}\n"
124
+ end
125
+ elsif type == "/Btn"
126
+ # For checkboxes (button fields that aren't radio), encode value as PDF name
127
+ # to match the /AS appearance state format (/Yes or /Off)
128
+ dict += " /V #{DictScan.encode_pdf_name(normalized_field_value)}\n"
129
+ else
130
+ dict += " /V #{DictScan.encode_pdf_string(normalized_field_value)}\n"
131
+ end
132
+ end
133
+
134
+ # Apply other metadata entries (excluding Ff and DA which we handled above)
135
+ @metadata.each do |key, val|
136
+ next if key == :Ff
137
+ next if key == :DA && type == "/Tx"
138
+
139
+ pdf_key = DictScan.format_pdf_key(key)
140
+ pdf_value = DictScan.format_pdf_value(val)
141
+ dict += " #{pdf_key} #{pdf_value}\n"
142
+ end
143
+
144
+ dict += ">>"
145
+ dict
146
+ end
147
+
148
+ def create_widget_annotation_with_parent(_widget_obj_num, parent_ref, page_ref, x, y, width, height, type, value,
149
+ is_radio: false)
150
+ rect_array = "[#{x} #{y} #{x + width} #{y + height}]"
151
+ widget = "<<\n"
152
+ widget += " /Type /Annot\n"
153
+ widget += " /Subtype /Widget\n"
154
+ widget += " /Parent #{parent_ref[0]} #{parent_ref[1]} R\n"
155
+ widget += " /P #{page_ref[0]} #{page_ref[1]} R\n" if page_ref
156
+
157
+ widget += " /FT #{type}\n"
158
+ if is_radio
159
+ widget += " /T #{DictScan.encode_pdf_string(@name)}\n"
160
+ else
161
+
162
+ should_set_value = if type == "/Sig" && value && !value.empty?
163
+ !(value.is_a?(String) && (value.start_with?("data:image/") || (value.length > 50 && value.match?(%r{^[A-Za-z0-9+/]*={0,2}$}))))
164
+ elsif type == "/Btn"
165
+ true
166
+ else
167
+ true
168
+ end
169
+
170
+ if type == "/Btn" && should_set_value
171
+ # For checkboxes, encode value as PDF name to match /AS appearance state format
172
+ value_str = value.to_s
173
+ is_checked = ["Yes", "/Yes", "true"].include?(value_str) || value == true
174
+ checkbox_value = is_checked ? "Yes" : "Off"
175
+ widget += " /V #{DictScan.encode_pdf_name(checkbox_value)}\n"
176
+ elsif should_set_value && value && !value.empty?
177
+ widget += " /V #{DictScan.encode_pdf_string(value)}\n"
178
+ end
179
+ end
180
+
181
+ widget += " /Rect #{rect_array}\n"
182
+ widget += " /F 4\n"
183
+
184
+ # Apply /DA from metadata for text fields only, otherwise use default behavior
185
+ if is_radio
186
+ widget += " /MK << /BC [0.0] /BG [1.0] >>\n"
187
+ elsif type == "/Tx" && @metadata[:DA]
188
+ da_value = DictScan.format_pdf_value(@metadata[:DA])
189
+ widget += " /DA #{da_value}\n"
190
+ else
191
+ widget += " /DA (/Helv 0 Tf 0 g)\n"
192
+ end
193
+
194
+ @metadata.each do |key, val|
195
+ pdf_key = DictScan.format_pdf_key(key)
196
+ pdf_value = DictScan.format_pdf_value(val)
197
+ next if ["/F", "/V"].include?(pdf_key)
198
+ next if is_radio && ["/Ff", "/DA"].include?(pdf_key)
199
+ # Skip /DA for text fields since we already handled it above
200
+ next if pdf_key == "/DA" && type == "/Tx"
201
+
202
+ widget += " #{pdf_key} #{pdf_value}\n"
203
+ end
204
+
205
+ widget += ">>"
206
+ widget
207
+ end
208
+
209
+ def add_field_to_acroform_with_defaults(field_obj_num)
210
+ af_ref = acroform_ref
211
+ return false unless af_ref
212
+
213
+ af_body = get_object_body_with_patch(af_ref)
214
+ # Use +"" instead of dup to create a mutable copy without keeping reference to original
215
+ patched = af_body.to_s
216
+
217
+ # Step 1: Add field to /Fields array
218
+ fields_array_ref = DictScan.value_token_after("/Fields", patched)
219
+
220
+ if fields_array_ref && fields_array_ref =~ /\A(\d+)\s+(\d+)\s+R/
221
+ arr_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
222
+ arr_body = get_object_body_with_patch(arr_ref)
223
+ new_body = DictScan.add_ref_to_array(arr_body, [field_obj_num, 0])
224
+ apply_patch(arr_ref, new_body, arr_body)
225
+ elsif patched.include?("/Fields")
226
+ patched = DictScan.add_ref_to_inline_array(patched, "/Fields", [field_obj_num, 0])
227
+ else
228
+ patched = DictScan.upsert_key_value(patched, "/Fields", "[#{field_obj_num} 0 R]")
229
+ end
230
+
231
+ # Step 2: Ensure /NeedAppearances false (we provide custom appearance streams)
232
+ # Setting to false tells viewers to use our custom appearances instead of generating defaults
233
+ # If we don't set this or set it to true, viewers will ignore our custom appearances and
234
+ # generate their own default appearances (e.g., circular radio buttons instead of our squares)
235
+ patched = if patched.include?("/NeedAppearances")
236
+ # Replace existing /NeedAppearances with false
237
+ DictScan.replace_key_value(patched, "/NeedAppearances", "false")
238
+ else
239
+ DictScan.upsert_key_value(patched, "/NeedAppearances", "false")
240
+ end
241
+
242
+ # Step 2.5: Remove /XFA if present
243
+ if patched.include?("/XFA")
244
+ xfa_pattern = %r{/XFA(?=[\s(<\[/])}
245
+ if patched.match(xfa_pattern)
246
+ xfa_value = DictScan.value_token_after("/XFA", patched)
247
+ if xfa_value
248
+ xfa_match = patched.match(xfa_pattern)
249
+ if xfa_match
250
+ key_start = xfa_match.begin(0)
251
+ value_start = xfa_match.end(0)
252
+ value_start += 1 while value_start < patched.length && patched[value_start] =~ /\s/
253
+ value_end = value_start + xfa_value.length
254
+ value_end += 1 while value_end < patched.length && patched[value_end] =~ /\s/
255
+ before = patched[0...key_start]
256
+ before = before.rstrip
257
+ after = patched[value_end..]
258
+ patched = "#{before} #{after.lstrip}".strip
259
+ patched = patched.gsub(/\s+/, " ")
260
+ end
261
+ end
262
+ end
263
+ end
264
+
265
+ # Step 3: Ensure /DR /Font has /Helv mapping
266
+ unless patched.include?("/DR") && patched.include?("/Helv")
267
+ font_obj_num = next_fresh_object_number
268
+ font_body = "<<\n /Type /Font\n /Subtype /Type1\n /BaseFont /Helvetica\n>>"
269
+ document.instance_variable_get(:@patches) << { ref: [font_obj_num, 0], body: font_body }
270
+
271
+ if patched.include?("/DR")
272
+ dr_tok = DictScan.value_token_after("/DR", patched)
273
+ if dr_tok && dr_tok.start_with?("<<")
274
+ unless dr_tok.include?("/Font")
275
+ new_dr_tok = dr_tok.chomp(">>") + " /Font << /Helv #{font_obj_num} 0 R >>\n>>"
276
+ patched = patched.sub(dr_tok) { |_| new_dr_tok }
277
+ end
278
+ else
279
+ patched = DictScan.replace_key_value(patched, "/DR", "<< /Font << /Helv #{font_obj_num} 0 R >> >>")
280
+ end
281
+ else
282
+ patched = DictScan.upsert_key_value(patched, "/DR", "<< /Font << /Helv #{font_obj_num} 0 R >> >>")
283
+ end
284
+ end
285
+
286
+ apply_patch(af_ref, patched, af_body)
287
+ true
288
+ end
289
+
290
+ def find_page_ref(page_num)
291
+ find_page_by_number(page_num)
292
+ end
293
+
294
+ def add_widget_to_page(widget_obj_num, page_num)
295
+ target_page_ref = find_page_ref(page_num)
296
+ return false unless target_page_ref
297
+
298
+ page_body = get_object_body_with_patch(target_page_ref)
299
+
300
+ new_body = if page_body =~ %r{/Annots\s*\[(.*?)\]}m
301
+ result = DictScan.add_ref_to_inline_array(page_body, "/Annots", [widget_obj_num, 0])
302
+ if result && result != page_body
303
+ result
304
+ else
305
+ annots_array = ::Regexp.last_match(1)
306
+ ref_token = "#{widget_obj_num} 0 R"
307
+ new_annots = if annots_array.strip.empty?
308
+ "[#{ref_token}]"
309
+ else
310
+ "[#{annots_array} #{ref_token}]"
311
+ end
312
+ page_body.sub(%r{/Annots\s*\[.*?\]}, "/Annots #{new_annots}")
313
+ end
314
+ elsif page_body =~ %r{/Annots\s+(\d+)\s+(\d+)\s+R}
315
+ annots_array_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
316
+ annots_array_body = get_object_body_with_patch(annots_array_ref)
317
+
318
+ ref_token = "#{widget_obj_num} 0 R"
319
+ if annots_array_body
320
+ new_annots_body = if annots_array_body.strip == "[]"
321
+ "[#{ref_token}]"
322
+ elsif annots_array_body.strip.start_with?("[") && annots_array_body.strip.end_with?("]")
323
+ without_brackets = annots_array_body.strip[1..-2].strip
324
+ "[#{without_brackets} #{ref_token}]"
325
+ else
326
+ "[#{annots_array_body} #{ref_token}]"
327
+ end
328
+
329
+ apply_patch(annots_array_ref, new_annots_body, annots_array_body)
330
+ page_body
331
+ else
332
+ page_body.sub(%r{/Annots\s+\d+\s+\d+\s+R}, "/Annots [#{ref_token}]")
333
+ end
334
+ else
335
+ ref_token = "#{widget_obj_num} 0 R"
336
+ if page_body.include?(">>")
337
+ page_body.reverse.sub(">>".reverse, "/Annots [#{ref_token}]>>".reverse).reverse
338
+ else
339
+ page_body + " /Annots [#{ref_token}]"
340
+ end
341
+ end
342
+
343
+ apply_patch(target_page_ref, new_body, page_body) if new_body && new_body != page_body
344
+ true
345
+ end
346
+
347
+ def add_widget_to_parent_kids(parent_ref, widget_obj_num)
348
+ parent_body = get_object_body_with_patch(parent_ref)
349
+ return unless parent_body
350
+
351
+ kids_array_ref = DictScan.value_token_after("/Kids", parent_body)
352
+
353
+ if kids_array_ref && kids_array_ref =~ /\A(\d+)\s+(\d+)\s+R\z/
354
+ arr_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
355
+ arr_body = get_object_body_with_patch(arr_ref)
356
+ new_body = DictScan.add_ref_to_array(arr_body, [widget_obj_num, 0])
357
+ apply_patch(arr_ref, new_body, arr_body)
358
+ elsif kids_array_ref && kids_array_ref.start_with?("[")
359
+ new_body = DictScan.add_ref_to_inline_array(parent_body, "/Kids", [widget_obj_num, 0])
360
+ apply_patch(parent_ref, new_body, parent_body) if new_body && new_body != parent_body
361
+ else
362
+ new_body = DictScan.upsert_key_value(parent_body, "/Kids", "[#{widget_obj_num} 0 R]")
363
+ apply_patch(parent_ref, new_body, parent_body) if new_body && new_body != parent_body
364
+ end
365
+ end
366
+
367
+ def build_form_xobject(content_stream, width, height)
368
+ dict = "<<\n"
369
+ dict += " /Type /XObject\n"
370
+ dict += " /Subtype /Form\n"
371
+ dict += " /BBox [0 0 #{width} #{height}]\n"
372
+ dict += " /Matrix [1 0 0 1 0 0]\n"
373
+ dict += " /Resources << >>\n"
374
+ dict += " /Length #{content_stream.bytesize}\n"
375
+ dict += ">>\n"
376
+ dict += "stream\n"
377
+ dict += content_stream
378
+ dict += "\nendstream"
379
+
380
+ dict
381
+ end
382
+ end
383
+ end
384
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CorpPdf
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.to_s
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
+ # Set /V to match /AS - both should be PDF names for checkboxes
62
+ v_value = DictScan.encode_pdf_name(normalized_value)
63
+
64
+ as_value = if normalized_value == "Yes"
65
+ "/Yes"
66
+ else
67
+ "/Off"
68
+ end
69
+
70
+ # Update /V to ensure it matches /AS format (both PDF names)
71
+ widget_body = if widget_body.include?("/V")
72
+ DictScan.replace_key_value(widget_body, "/V", v_value)
73
+ else
74
+ DictScan.upsert_key_value(widget_body, "/V", v_value)
75
+ end
76
+
77
+ # Update /AS to match the normalized value
78
+ widget_body = if widget_body.include?("/AS")
79
+ DictScan.replace_key_value(widget_body, "/AS", as_value)
80
+ else
81
+ DictScan.upsert_key_value(widget_body, "/AS", as_value)
82
+ end
83
+
84
+ apply_patch(widget_ref, widget_body, original_widget_body)
85
+ end
86
+
87
+ def create_checkbox_yes_appearance(width, height)
88
+ line_width = [width * 0.05, height * 0.05].min
89
+ border_width = [width * 0.08, height * 0.08].min
90
+
91
+ # Define checkmark in normalized coordinates (0-1 range) for consistent aspect ratio
92
+ # Checkmark shape: three points forming a checkmark
93
+ norm_x1 = 0.25
94
+ norm_y1 = 0.55
95
+ norm_x2 = 0.45
96
+ norm_y2 = 0.35
97
+ norm_x3 = 0.75
98
+ norm_y3 = 0.85
99
+
100
+ # Calculate scale to maximize size while maintaining aspect ratio
101
+ # Use the smaller dimension to ensure it fits
102
+ scale = [width, height].min * 0.85 # Use 85% of the smaller dimension
103
+
104
+ # Calculate checkmark dimensions
105
+ check_width = scale
106
+ check_height = scale
107
+
108
+ # Center the checkmark in the box
109
+ offset_x = (width - check_width) / 2
110
+ offset_y = (height - check_height) / 2
111
+
112
+ # Calculate actual coordinates
113
+ check_x1 = offset_x + (norm_x1 * check_width)
114
+ check_y1 = offset_y + (norm_y1 * check_height)
115
+ check_x2 = offset_x + (norm_x2 * check_width)
116
+ check_y2 = offset_y + (norm_y2 * check_height)
117
+ check_x3 = offset_x + (norm_x3 * check_width)
118
+ check_y3 = offset_y + (norm_y3 * check_height)
119
+
120
+ content_stream = "q\n"
121
+ # Draw square border around field bounds
122
+ content_stream += "0 0 0 RG\n" # Black stroke color
123
+ content_stream += "#{line_width} w\n" # Line width
124
+ # Draw rectangle from (0,0) to (width, height)
125
+ content_stream += "0 0 m\n"
126
+ content_stream += "#{width} 0 l\n"
127
+ content_stream += "#{width} #{height} l\n"
128
+ content_stream += "0 #{height} l\n"
129
+ content_stream += "0 0 l\n"
130
+ content_stream += "S\n" # Stroke the border
131
+
132
+ # Draw checkmark
133
+ content_stream += "0 0 0 rg\n" # Black fill color
134
+ content_stream += "#{border_width} w\n" # Line width for checkmark
135
+ content_stream += "#{check_x1} #{check_y1} m\n"
136
+ content_stream += "#{check_x2} #{check_y2} l\n"
137
+ content_stream += "#{check_x3} #{check_y3} l\n"
138
+ content_stream += "S\n" # Stroke the checkmark
139
+ content_stream += "Q\n"
140
+
141
+ build_form_xobject(content_stream, width, height)
142
+ end
143
+
144
+ def create_checkbox_off_appearance(width, height)
145
+ line_width = [width * 0.05, height * 0.05].min
146
+
147
+ content_stream = "q\n"
148
+ # Draw square border around field bounds
149
+ content_stream += "0 0 0 RG\n" # Black stroke color
150
+ content_stream += "#{line_width} w\n" # Line width
151
+ # Draw rectangle from (0,0) to (width, height)
152
+ content_stream += "0 0 m\n"
153
+ content_stream += "#{width} 0 l\n"
154
+ content_stream += "#{width} #{height} l\n"
155
+ content_stream += "0 #{height} l\n"
156
+ content_stream += "0 0 l\n"
157
+ content_stream += "S\n" # Stroke the border
158
+ content_stream += "Q\n"
159
+
160
+ build_form_xobject(content_stream, width, height)
161
+ end
162
+ end
163
+ end
164
+ end