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,663 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CorpPdf
4
+ module Actions
5
+ # Action to update a field's value and optionally rename it in a PDF document
6
+ class UpdateField
7
+ include Base
8
+
9
+ def initialize(document, name, new_value, new_name: nil)
10
+ @document = document
11
+ @name = name
12
+ @new_value = new_value
13
+ @new_name = new_name
14
+ end
15
+
16
+ def call
17
+ # First try to find in list_fields (already written fields)
18
+ fld = @document.list_fields.find { |f| f.name == @name }
19
+
20
+ # If not found, check if field was just added (in patches) and create a Field object for it
21
+ unless fld
22
+ patches = @document.instance_variable_get(:@patches)
23
+ field_patch = patches.find do |p|
24
+ next unless p[:body]
25
+ next unless p[:body].include?("/T")
26
+
27
+ t_tok = DictScan.value_token_after("/T", p[:body])
28
+ next unless t_tok
29
+
30
+ field_name = DictScan.decode_pdf_string(t_tok)
31
+ field_name == @name
32
+ end
33
+
34
+ if field_patch && field_patch[:body].include?("/FT")
35
+ ft_tok = DictScan.value_token_after("/FT", field_patch[:body])
36
+ if ft_tok
37
+ # Create a temporary Field object for newly added field
38
+ position = {}
39
+ fld = Field.new(@name, nil, ft_tok, field_patch[:ref], @document, position)
40
+ end
41
+ end
42
+ end
43
+
44
+ return false unless fld
45
+
46
+ # Check if this is a signature field and if new_value looks like image data
47
+ if fld.signature_field?
48
+ # Check if new_value looks like base64 image data or data URI
49
+ image_data = @new_value
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 using Signature field class
52
+ result = CorpPdf::Fields::Signature.add_appearance(@document, fld.ref, image_data)
53
+ return result if result
54
+ # If appearance fails, fall through to normal update
55
+ end
56
+ end
57
+
58
+ original = get_object_body_with_patch(fld.ref)
59
+ return false unless original
60
+
61
+ # Determine if this is a widget annotation or field object
62
+ is_widget = original.include?("/Subtype /Widget")
63
+ field_ref = fld.ref # Default: the ref we found is the field
64
+
65
+ # If this is a widget, we need to also update the parent field object (if it exists)
66
+ # Otherwise, this widget IS the field (flat structure)
67
+ if is_widget
68
+ parent_tok = DictScan.value_token_after("/Parent", original)
69
+ if parent_tok && parent_tok =~ /\A(\d+)\s+(\d+)\s+R/
70
+ field_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
71
+ field_body = get_object_body_with_patch(field_ref)
72
+ if field_body && !field_body.include?("/Subtype /Widget")
73
+ new_field_body = patch_field_value_body(field_body, @new_value)
74
+
75
+ # Check if multiline and remove appearance stream from parent field too
76
+ is_multiline = DictScan.is_multiline_field?(field_body) || DictScan.is_multiline_field?(new_field_body)
77
+ if is_multiline
78
+ new_field_body = DictScan.remove_appearance_stream(new_field_body)
79
+ end
80
+
81
+ if new_field_body && new_field_body.include?("<<") && new_field_body.include?(">>")
82
+ apply_patch(field_ref, new_field_body, field_body)
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ # Update the object we found (widget or field) - always update what we found
89
+ new_body = patch_field_value_body(original, @new_value)
90
+
91
+ # Check if this is a multiline field - if so, remove appearance stream
92
+ # macOS Preview needs appearance streams to be regenerated for multiline fields
93
+ is_multiline = check_if_multiline_field(field_ref)
94
+ if is_multiline
95
+ new_body = DictScan.remove_appearance_stream(new_body)
96
+ end
97
+
98
+ # Update field name (/T) if requested
99
+ if @new_name && !@new_name.empty?
100
+ new_body = patch_field_name_body(new_body, @new_name)
101
+ end
102
+
103
+ # Validate the patched body is valid before adding to patches
104
+ unless new_body && new_body.include?("<<") && new_body.include?(">>")
105
+ warn "Warning: Invalid patched body for #{fld.ref.inspect}, skipping update"
106
+ return false
107
+ end
108
+
109
+ apply_patch(fld.ref, new_body, original)
110
+
111
+ # If we renamed the field, also update the parent field object and all widgets
112
+ if @new_name && !@new_name.empty?
113
+ # Update parent field object if it exists (separate from widget)
114
+ if field_ref != fld.ref
115
+ field_body = get_object_body_with_patch(field_ref)
116
+ if field_body && !field_body.include?("/Subtype /Widget")
117
+ new_field_body = patch_field_name_body(field_body, @new_name)
118
+ if new_field_body && new_field_body.include?("<<") && new_field_body.include?(">>")
119
+ apply_patch(field_ref, new_field_body, field_body)
120
+ end
121
+ end
122
+ end
123
+
124
+ # Update all widget annotations that reference this field
125
+ update_widget_names_for_field(field_ref, @new_name)
126
+ end
127
+
128
+ # Also update any widget annotations that reference this field via /Parent
129
+ update_widget_annotations_for_field(field_ref, @new_value)
130
+
131
+ # If this is a checkbox without appearance streams, create them
132
+ if fld.button_field?
133
+ # Check if it's a checkbox (not a radio button) by checking field flags
134
+ field_body = get_object_body_with_patch(field_ref)
135
+ is_radio = false
136
+ if field_body
137
+ field_flags_match = field_body.match(%r{/Ff\s+(\d+)})
138
+ if field_flags_match
139
+ field_flags = field_flags_match[1].to_i
140
+ # Radio button flag is bit 15 = 32768
141
+ is_radio = field_flags.anybits?(32_768)
142
+ end
143
+ end
144
+
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
150
+ widget_ref = find_checkbox_widget(fld.ref)
151
+ if widget_ref
152
+ widget_body = get_object_body_with_patch(widget_ref)
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
+ end
158
+ end
159
+ end
160
+ end
161
+
162
+ # Best-effort: set NeedAppearances to true so viewers regenerate appearances
163
+ ensure_need_appearances
164
+
165
+ true
166
+ end
167
+
168
+ private
169
+
170
+ def patch_field_value_body(dict_body, new_value)
171
+ # Simple, reliable approach: Use DictScan methods that preserve structure
172
+ # Don't manipulate the dictionary body - let DictScan handle it
173
+
174
+ # Ensure we have a valid dictionary
175
+ return dict_body unless dict_body&.include?("<<")
176
+
177
+ # For checkboxes (/Btn fields), normalize value to "Yes" or "Off"
178
+ ft_pattern = %r{/FT\s+/Btn}
179
+ is_button_field = ft_pattern.match(dict_body)
180
+
181
+ # Check if it's a radio button by checking field flags
182
+ # For widgets, check the parent field's flags since widgets don't have /Ff directly
183
+ is_radio = false
184
+ if is_button_field
185
+ field_flags_match = dict_body.match(%r{/Ff\s+(\d+)})
186
+ if field_flags_match
187
+ field_flags = field_flags_match[1].to_i
188
+ # Radio button flag is bit 15 = 32768
189
+ is_radio = field_flags.anybits?(32_768)
190
+ elsif dict_body.include?("/Parent")
191
+ # This is a widget - check parent field's flags
192
+ parent_tok = DictScan.value_token_after("/Parent", dict_body)
193
+ if parent_tok && parent_tok =~ /\A(\d+)\s+(\d+)\s+R/
194
+ parent_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
195
+ parent_body = get_object_body_with_patch(parent_ref)
196
+ if parent_body
197
+ parent_flags_match = parent_body.match(%r{/Ff\s+(\d+)})
198
+ if parent_flags_match
199
+ parent_flags = parent_flags_match[1].to_i
200
+ is_radio = parent_flags.anybits?(32_768)
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
206
+
207
+ normalized_value = if is_button_field && !is_radio
208
+ # For checkboxes, normalize to "Yes" or "Off"
209
+ # Accept "Yes", "/Yes" (PDF name format), true (boolean), or "true" (string)
210
+ value_str = new_value.to_s
211
+ is_checked = ["Yes", "/Yes", "true"].include?(value_str) || new_value == true
212
+ is_checked ? "Yes" : "Off"
213
+ else
214
+ new_value
215
+ end
216
+
217
+ # Encode the normalized value
218
+ # For checkboxes, use PDF name format to match /AS appearance state format
219
+ # For radio buttons and other fields, use PDF string format
220
+ v_token = if is_button_field && !is_radio
221
+ DictScan.encode_pdf_name(normalized_value)
222
+ else
223
+ DictScan.encode_pdf_string(normalized_value)
224
+ end
225
+
226
+ # Find /V using pattern matching to ensure we get the complete key
227
+ v_key_pattern = %r{/V(?=[\s(<\[/])}
228
+ has_v = dict_body.match(v_key_pattern)
229
+
230
+ # Update /V - use replace_key_value which handles the replacement carefully
231
+ patched = if has_v
232
+ DictScan.replace_key_value(dict_body, "/V", v_token)
233
+ else
234
+ DictScan.upsert_key_value(dict_body, "/V", v_token)
235
+ end
236
+
237
+ # Verify replacement worked and dictionary is still valid
238
+ unless patched && patched.include?("<<") && patched.include?(">>")
239
+ warn "Warning: Dictionary corrupted after /V replacement"
240
+ return dict_body # Return original if corrupted
241
+ end
242
+
243
+ # Update /AS for checkboxes/radio buttons if needed
244
+ # Check for /FT /Btn more carefully
245
+ if ft_pattern.match(patched)
246
+ # For button fields, set /AS based on normalized value
247
+ as_value = if normalized_value == "Yes"
248
+ "/Yes"
249
+ else
250
+ "/Off"
251
+ end
252
+
253
+ # Only set /AS if /AP exists (appearance dictionary is present)
254
+ # If /AP doesn't exist, we can't set /AS properly
255
+ if patched.include?("/AP")
256
+ as_pattern = %r{/AS(?=[\s(<\[/])}
257
+ has_as = patched.match(as_pattern)
258
+
259
+ patched = if has_as
260
+ DictScan.replace_key_value(patched, "/AS", as_value)
261
+ else
262
+ DictScan.upsert_key_value(patched, "/AS", as_value)
263
+ end
264
+
265
+ # Verify /AS replacement worked
266
+ unless patched && patched.include?("<<") && patched.include?(">>")
267
+ warn "Warning: Dictionary corrupted after /AS replacement"
268
+ # Revert to before /AS change
269
+ return DictScan.replace_key_value(dict_body, "/V", v_token) if has_v
270
+
271
+ return dict_body
272
+ end
273
+ end
274
+ end
275
+
276
+ patched
277
+ end
278
+
279
+ def patch_field_name_body(dict_body, new_name)
280
+ # Ensure we have a valid dictionary
281
+ return dict_body unless dict_body&.include?("<<")
282
+
283
+ # Encode the new name
284
+ t_token = DictScan.encode_pdf_string(new_name)
285
+
286
+ # Find /T using pattern matching
287
+ t_key_pattern = %r{/T(?=[\s(<\[/])}
288
+ has_t = dict_body.match(t_key_pattern)
289
+
290
+ # Update /T - use replace_key_value which handles the replacement carefully
291
+ patched = if has_t
292
+ DictScan.replace_key_value(dict_body, "/T", t_token)
293
+ else
294
+ DictScan.upsert_key_value(dict_body, "/T", t_token)
295
+ end
296
+
297
+ # Verify replacement worked and dictionary is still valid
298
+ unless patched && patched.include?("<<") && patched.include?(">>")
299
+ warn "Warning: Dictionary corrupted after /T replacement"
300
+ return dict_body # Return original if corrupted
301
+ end
302
+
303
+ patched
304
+ end
305
+
306
+ def update_widget_annotations_for_field(field_ref, new_value)
307
+ # Check if the field is multiline by looking at the field object
308
+ field_body = get_object_body_with_patch(field_ref)
309
+ is_multiline = field_body && DictScan.is_multiline_field?(field_body)
310
+
311
+ resolver.each_object do |ref, body|
312
+ next unless body
313
+ next unless DictScan.is_widget?(body)
314
+ next unless body.include?("/Parent")
315
+
316
+ body = get_object_body_with_patch(ref)
317
+
318
+ parent_tok = DictScan.value_token_after("/Parent", body)
319
+ next unless parent_tok && parent_tok =~ /\A(\d+)\s+(\d+)\s+R/
320
+
321
+ widget_parent_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
322
+ next unless widget_parent_ref == field_ref
323
+
324
+ widget_body_patched = patch_field_value_body(body, new_value)
325
+
326
+ # For multiline fields, remove appearance stream from widgets too
327
+ if is_multiline
328
+ widget_body_patched = DictScan.remove_appearance_stream(widget_body_patched)
329
+ end
330
+
331
+ apply_patch(ref, widget_body_patched, body)
332
+ end
333
+ end
334
+
335
+ def update_widget_names_for_field(field_ref, new_name)
336
+ resolver.each_object do |ref, body|
337
+ next unless body
338
+ next unless DictScan.is_widget?(body)
339
+
340
+ body = get_object_body_with_patch(ref)
341
+
342
+ # Match widgets by /Parent reference
343
+ if body.include?("/Parent")
344
+ parent_tok = DictScan.value_token_after("/Parent", body)
345
+ if parent_tok && parent_tok =~ /\A(\d+)\s+(\d+)\s+R/
346
+ widget_parent_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
347
+ if widget_parent_ref == field_ref
348
+ widget_body_patched = patch_field_name_body(body, new_name)
349
+ apply_patch(ref, widget_body_patched, body)
350
+ end
351
+ end
352
+ end
353
+
354
+ # Also match widgets by field name (/T) - some widgets might not have /Parent
355
+ next unless body.include?("/T")
356
+
357
+ t_tok = DictScan.value_token_after("/T", body)
358
+ next unless t_tok
359
+
360
+ widget_name = DictScan.decode_pdf_string(t_tok)
361
+ if widget_name && widget_name == @name
362
+ widget_body_patched = patch_field_name_body(body, new_name)
363
+ apply_patch(ref, widget_body_patched, body)
364
+ end
365
+ end
366
+ end
367
+
368
+ def ensure_need_appearances
369
+ af_ref = acroform_ref
370
+ return unless af_ref
371
+
372
+ acro_body = get_object_body_with_patch(af_ref)
373
+ # Set /NeedAppearances false to use our custom appearance streams
374
+ # If we set it to true, viewers will ignore our custom appearances and generate defaults
375
+ # (e.g., circular radio buttons instead of our square checkboxes)
376
+ acro_patched = if acro_body.include?("/NeedAppearances")
377
+ DictScan.replace_key_value(acro_body, "/NeedAppearances", "false")
378
+ else
379
+ DictScan.upsert_key_value(acro_body, "/NeedAppearances", "false")
380
+ end
381
+ apply_patch(af_ref, acro_patched, acro_body)
382
+ end
383
+
384
+ def check_if_multiline_field(field_ref)
385
+ field_body = get_object_body_with_patch(field_ref)
386
+ return false unless field_body
387
+
388
+ DictScan.is_multiline_field?(field_body)
389
+ end
390
+
391
+ def find_checkbox_widget(field_ref)
392
+ # Check patches first
393
+ patches = @document.instance_variable_get(:@patches)
394
+ patches.each do |patch|
395
+ next unless patch[:body]
396
+ next unless DictScan.is_widget?(patch[:body])
397
+
398
+ # Check if widget has /Parent pointing to field_ref
399
+ if patch[:body] =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R}
400
+ parent_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
401
+ return patch[:ref] if parent_ref == field_ref
402
+ end
403
+
404
+ # Also check if widget IS the field (flat structure)
405
+ if patch[:body].include?("/FT") && DictScan.value_token_after("/FT",
406
+ patch[:body]) == "/Btn" && (patch[:ref] == field_ref)
407
+ return patch[:ref]
408
+ end
409
+ end
410
+
411
+ # Then check resolver (for existing widgets)
412
+ resolver.each_object do |ref, body|
413
+ next unless body && DictScan.is_widget?(body)
414
+
415
+ # Check if widget has /Parent pointing to field_ref
416
+ if body =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R}
417
+ parent_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
418
+ return ref if parent_ref == field_ref
419
+ end
420
+
421
+ # Also check if widget IS the field (flat structure)
422
+ if body.include?("/FT") && DictScan.value_token_after("/FT", body) == "/Btn" && (ref == field_ref)
423
+ return ref
424
+ end
425
+ end
426
+
427
+ # Fallback: if field_ref itself is a widget
428
+ body = get_object_body_with_patch(field_ref)
429
+ return field_ref if body && DictScan.is_widget?(body) && body.include?("/FT") && DictScan.value_token_after(
430
+ "/FT", body
431
+ ) == "/Btn"
432
+
433
+ nil
434
+ end
435
+
436
+ def update_radio_button_appearances(parent_ref)
437
+ # Find all widgets that are children of this parent field
438
+ widgets = []
439
+
440
+ # Check patches first
441
+ patches = @document.instance_variable_get(:@patches)
442
+ patches.each do |patch|
443
+ next unless patch[:body]
444
+ next unless DictScan.is_widget?(patch[:body])
445
+
446
+ next unless patch[:body] =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R}
447
+
448
+ widget_parent_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
449
+ if widget_parent_ref == parent_ref
450
+ widgets << patch[:ref]
451
+ end
452
+ end
453
+
454
+ # Also check resolver (for existing widgets)
455
+ resolver.each_object do |ref, body|
456
+ next unless body && DictScan.is_widget?(body)
457
+
458
+ next unless body =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R}
459
+
460
+ widget_parent_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
461
+ if (widget_parent_ref == parent_ref) && !widgets.include?(ref)
462
+ widgets << ref
463
+ end
464
+ end
465
+
466
+ # Update appearance for each widget using Radio class method
467
+ widgets.each do |widget_ref|
468
+ widget_body = get_object_body_with_patch(widget_ref)
469
+ next unless widget_body
470
+
471
+ # Get widget dimensions
472
+ rect = extract_widget_rect(widget_body)
473
+ next unless rect && rect[:width].positive? && rect[:height].positive?
474
+
475
+ # Get export value from widget's /AP /N dictionary
476
+ export_value = nil
477
+ if widget_body.include?("/AP")
478
+ ap_tok = DictScan.value_token_after("/AP", widget_body)
479
+ if ap_tok && ap_tok.start_with?("<<")
480
+ n_tok = DictScan.value_token_after("/N", ap_tok)
481
+ if n_tok && n_tok.start_with?("<<")
482
+ # Extract export value (not /Off)
483
+ export_values = n_tok.scan(%r{/([^\s<>\[\]]+)\s+\d+\s+\d+\s+R}).flatten.reject { |v| v == "Off" }
484
+ export_value = export_values.first if export_values.any?
485
+ end
486
+ end
487
+ end
488
+
489
+ # If no export value found, generate one
490
+ export_value ||= "widget_#{widget_ref[0]}"
491
+
492
+ # Create a Radio instance to reuse appearance creation logic
493
+ radio_handler = CorpPdf::Fields::Radio.new(@document, "", { width: rect[:width], height: rect[:height] })
494
+ radio_handler.send(
495
+ :add_radio_button_appearance,
496
+ widget_ref[0],
497
+ export_value,
498
+ 0, 0, # x, y not needed when overwriting
499
+ rect[:width],
500
+ rect[:height],
501
+ parent_ref
502
+ )
503
+ end
504
+ end
505
+
506
+ def extract_widget_rect(widget_body)
507
+ return nil unless widget_body
508
+
509
+ rect_tok = DictScan.value_token_after("/Rect", widget_body)
510
+ return nil unless rect_tok&.start_with?("[")
511
+
512
+ rect_values = rect_tok.scan(/[-+]?\d*\.?\d+/).map(&:to_f)
513
+ return nil unless rect_values.length == 4
514
+
515
+ x1, y1, x2, y2 = rect_values
516
+ width = (x2 - x1).abs
517
+ height = (y2 - y1).abs
518
+
519
+ return nil if width <= 0 || height <= 0
520
+
521
+ { x: x1, y: y1, width: width, height: height }
522
+ end
523
+
524
+ def add_checkbox_appearance(widget_ref, width, height)
525
+ # Create appearance form XObjects for Yes and Off states
526
+ yes_obj_num = next_fresh_object_number
527
+ off_obj_num = yes_obj_num + 1
528
+
529
+ # Create Yes appearance (checked box with checkmark)
530
+ yes_body = create_checkbox_yes_appearance(width, height)
531
+ @document.instance_variable_get(:@patches) << { ref: [yes_obj_num, 0], body: yes_body }
532
+
533
+ # Create Off appearance (empty box)
534
+ off_body = create_checkbox_off_appearance(width, height)
535
+ @document.instance_variable_get(:@patches) << { ref: [off_obj_num, 0], body: off_body }
536
+
537
+ # Get current widget body and add /AP dictionary
538
+ original_widget_body = get_object_body_with_patch(widget_ref)
539
+ widget_body = original_widget_body.dup
540
+
541
+ # Create /AP dictionary with Yes and Off appearances
542
+ ap_dict = "<<\n /N <<\n /Yes #{yes_obj_num} 0 R\n /Off #{off_obj_num} 0 R\n >>\n>>"
543
+
544
+ # Add /AP to widget
545
+ if widget_body.include?("/AP")
546
+ # Replace existing /AP
547
+ ap_key_pattern = %r{/AP(?=[\s(<\[/])}
548
+ if widget_body.match(ap_key_pattern)
549
+ widget_body = DictScan.replace_key_value(widget_body, "/AP", ap_dict)
550
+ end
551
+ else
552
+ # Insert /AP before closing >>
553
+ widget_body = DictScan.upsert_key_value(widget_body, "/AP", ap_dict)
554
+ end
555
+
556
+ # Set /AS based on the value - use the EXACT same normalization logic as widget creation
557
+ # This ensures consistency between /V and /AS
558
+ # Normalize value: "Yes" if truthy (Yes, "/Yes", true, etc.), otherwise "Off"
559
+ value_str = @new_value.to_s
560
+ is_checked = value_str == "Yes" || value_str == "/Yes" || value_str == "true" || @new_value == true
561
+ normalized_value = is_checked ? "Yes" : "Off"
562
+
563
+ # Set /AS to match normalized value (same as what was set for /V in widget creation)
564
+ as_value = if normalized_value == "Yes"
565
+ "/Yes"
566
+ else
567
+ "/Off"
568
+ end
569
+
570
+ widget_body = if widget_body.include?("/AS")
571
+ DictScan.replace_key_value(widget_body, "/AS", as_value)
572
+ else
573
+ DictScan.upsert_key_value(widget_body, "/AS", as_value)
574
+ end
575
+
576
+ apply_patch(widget_ref, widget_body, original_widget_body)
577
+ end
578
+
579
+ def create_checkbox_yes_appearance(width, height)
580
+ line_width = [width * 0.05, height * 0.05].min
581
+ border_width = [width * 0.08, height * 0.08].min
582
+
583
+ # Define checkmark in normalized coordinates (0-1 range) for consistent aspect ratio
584
+ # Checkmark shape: three points forming a checkmark
585
+ norm_x1 = 0.25
586
+ norm_y1 = 0.55
587
+ norm_x2 = 0.45
588
+ norm_y2 = 0.35
589
+ norm_x3 = 0.75
590
+ norm_y3 = 0.85
591
+
592
+ # Calculate scale to maximize size while maintaining aspect ratio
593
+ # Use the smaller dimension to ensure it fits
594
+ scale = [width, height].min * 0.85 # Use 85% of the smaller dimension
595
+
596
+ # Calculate checkmark dimensions
597
+ check_width = scale
598
+ check_height = scale
599
+
600
+ # Center the checkmark in the box
601
+ offset_x = (width - check_width) / 2
602
+ offset_y = (height - check_height) / 2
603
+
604
+ # Calculate actual coordinates
605
+ check_x1 = offset_x + (norm_x1 * check_width)
606
+ check_y1 = offset_y + (norm_y1 * check_height)
607
+ check_x2 = offset_x + (norm_x2 * check_width)
608
+ check_y2 = offset_y + (norm_y2 * check_height)
609
+ check_x3 = offset_x + (norm_x3 * check_width)
610
+ check_y3 = offset_y + (norm_y3 * check_height)
611
+
612
+ content_stream = "q\n"
613
+ # Draw square border around field bounds
614
+ content_stream += "0 0 0 RG\n" # Black stroke color
615
+ content_stream += "#{line_width} w\n" # Line width
616
+ # Draw rectangle from (0,0) to (width, height)
617
+ content_stream += "0 0 m\n"
618
+ content_stream += "#{width} 0 l\n"
619
+ content_stream += "#{width} #{height} l\n"
620
+ content_stream += "0 #{height} l\n"
621
+ content_stream += "0 0 l\n"
622
+ content_stream += "S\n" # Stroke the border
623
+
624
+ # Draw checkmark
625
+ content_stream += "0 0 0 rg\n" # Black fill color
626
+ content_stream += "#{border_width} w\n" # Line width for checkmark
627
+ content_stream += "#{check_x1} #{check_y1} m\n"
628
+ content_stream += "#{check_x2} #{check_y2} l\n"
629
+ content_stream += "#{check_x3} #{check_y3} l\n"
630
+ content_stream += "S\n" # Stroke the checkmark
631
+ content_stream += "Q\n"
632
+
633
+ build_form_xobject(content_stream, width, height)
634
+ end
635
+
636
+ def create_checkbox_off_appearance(width, height)
637
+ # Create a form XObject for unchecked checkbox
638
+ # Empty appearance (no border, no checkmark) - viewer will draw default checkbox
639
+
640
+ content_stream = "q\n"
641
+ # Empty appearance for unchecked state
642
+ content_stream += "Q\n"
643
+
644
+ build_form_xobject(content_stream, width, height)
645
+ end
646
+
647
+ def build_form_xobject(content_stream, width, height)
648
+ # Build a Form XObject dictionary with the given content stream
649
+ dict = "<<\n"
650
+ dict += " /Type /XObject\n"
651
+ dict += " /Subtype /Form\n"
652
+ dict += " /BBox [0 0 #{width} #{height}]\n"
653
+ dict += " /Length #{content_stream.bytesize}\n"
654
+ dict += ">>\n"
655
+ dict += "stream\n"
656
+ dict += content_stream
657
+ dict += "\nendstream"
658
+
659
+ dict
660
+ end
661
+ end
662
+ end
663
+ end