acro_that 0.1.5 → 0.1.7

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.
@@ -2,30 +2,21 @@
2
2
 
3
3
  This document identifies code duplication and unused methods that could be refactored to improve maintainability.
4
4
 
5
- ## 1. Duplicated Page-Finding Logic
5
+ ## 1. Duplicated Page-Finding Logic ✅ **COMPLETED**
6
6
 
7
- ### Issue
8
- Multiple methods have similar logic for finding page objects in a PDF document.
9
-
10
- ### Locations
11
- - `Document#list_pages` (lines 75-104)
12
- - `Document#collect_pages_from_tree` (lines 691-712)
13
- - `Document#find_page_number_for_ref` (lines 714-728)
14
- - `AddField#find_page_ref` (lines 155-211)
15
-
16
- ### Pattern
17
- The pattern `body.include?("/Type /Page") || body =~ %r{/Type\s*/Page(?!s)\b}` appears in multiple places with slight variations.
7
+ ### Status
8
+ **RESOLVED** - This refactoring has been completed:
9
+ - ✅ `DictScan.is_page?(body)` exists (line 320 in dict_scan.rb)
10
+ - ✅ `Document#find_all_pages` exists (line 693 in document.rb)
11
+ - `Document#find_page_by_number(page_num)` exists (line 725 in document.rb)
12
+ - `Base#find_page_by_number` delegates to Document
13
+ - `AddField#find_page_ref` now uses `find_page_by_number` (line 288)
18
14
 
19
- ### Suggested Refactor
20
- Create a shared module or utility methods in `DictScan`:
21
- - `DictScan.is_page?(body)` - Check if a body represents a page object
22
- - `Document#find_all_pages` - Unified method to find all page objects
23
- - `Document#find_page_by_number(page_num)` - Find a specific page by number
15
+ ### Original Issue
16
+ Multiple methods had similar logic for finding page objects in a PDF document.
24
17
 
25
- ### Benefits
26
- - Single source of truth for page detection logic
27
- - Easier to maintain and update page-finding behavior
28
- - Consistent page ordering across methods
18
+ ### Resolution
19
+ All page-finding logic has been unified into `DictScan.is_page?` and `Document#find_all_pages` / `find_page_by_number`.
29
20
 
30
21
  ---
31
22
 
@@ -97,45 +88,18 @@ Extend `DictScan` with methods:
97
88
 
98
89
  ---
99
90
 
100
- ## 4. Duplicated Box Parsing Logic
91
+ ## 4. Duplicated Box Parsing Logic ✅ **COMPLETED**
101
92
 
102
- ### Issue
103
- `Document#list_pages` has repeated code blocks for parsing different box types (MediaBox, CropBox, ArtBox, BleedBox, TrimBox).
93
+ ### Status
94
+ **RESOLVED** - This refactoring has been completed:
95
+ - ✅ `DictScan.parse_box(body, box_type)` exists (line 340 in dict_scan.rb)
96
+ - ✅ `Document#list_pages` now uses `parse_box` for all box types (lines 89-99 in document.rb)
104
97
 
105
- ### Locations
106
- - `Document#list_pages` (lines 120-165)
98
+ ### Original Issue
99
+ `Document#list_pages` had repeated code blocks for parsing different box types (MediaBox, CropBox, ArtBox, BleedBox, TrimBox).
107
100
 
108
- ### Pattern
109
- Each box type uses identical logic:
110
- ```ruby
111
- if body =~ %r{/MediaBox\s*\[(.*?)\]}
112
- box_values = ::Regexp.last_match(1).scan(/[-+]?\d*\.?\d+/).map(&:to_f)
113
- if box_values.length == 4
114
- llx, lly, urx, ury = box_values
115
- media_box = { llx: llx, lly: lly, urx: urx, ury: ury }
116
- end
117
- end
118
- ```
119
-
120
- ### Suggested Refactor
121
- Create a helper method:
122
- ```ruby
123
- def parse_box(body, box_type)
124
- pattern = %r{/#{box_type}\s*\[(.*?)\]}
125
- return nil unless body =~ pattern
126
-
127
- box_values = ::Regexp.last_match(1).scan(/[-+]?\d*\.?\d+/).map(&:to_f)
128
- return nil unless box_values.length == 4
129
-
130
- llx, lly, urx, ury = box_values
131
- { llx: llx, lly: lly, urx: urx, ury: ury }
132
- end
133
- ```
134
-
135
- ### Benefits
136
- - Reduces code duplication from ~45 lines to ~10 lines per box type
137
- - Easier to add new box types
138
- - Consistent parsing logic
101
+ ### Resolution
102
+ Extracted the common box parsing logic into `DictScan.parse_box` helper method. All box type parsing in `Document#list_pages` now uses this shared method, reducing code duplication from ~45 lines to ~10 lines while maintaining existing functionality.
139
103
 
140
104
  ---
141
105
 
@@ -145,32 +109,23 @@ end
145
109
  The `next_fresh_object_number` method is implemented identically in two places.
146
110
 
147
111
  ### Locations
148
- - `Document#next_fresh_object_number` (lines 730-739)
112
+ - `Document#next_fresh_object_number` (lines 745-754)
149
113
  - `Base#next_fresh_object_number` (lines 28-37)
150
114
 
151
115
  ### Pattern
152
- Both methods have identical implementation:
153
- ```ruby
154
- def next_fresh_object_number
155
- max_obj_num = 0
156
- resolver.each_object do |ref, _|
157
- max_obj_num = [max_obj_num, ref[0]].max
158
- end
159
- patches.each do |p|
160
- max_obj_num = [max_obj_num, p[:ref][0]].max
161
- end
162
- max_obj_num + 1
163
- end
164
- ```
116
+ Both methods have identical implementation. However, `Document` doesn't include `Base`, so both need to exist independently.
165
117
 
166
118
  ### Suggested Refactor
167
- - Remove `Document#next_fresh_object_number` - it's only called within `Document` but could use `Base`'s implementation
168
- - Or: Document already has access to resolver and patches, so remove duplication by making Document use Base's method
119
+ - Consider whether `Document` should use `Base`'s implementation via delegation
120
+ - Or: Keep both implementations if Document needs independent access
169
121
 
170
122
  ### Benefits
171
123
  - Single implementation
172
124
  - Consistent object numbering logic
173
125
 
126
+ ### Note
127
+ This may be intentional since `Document` doesn't include `Base` - both classes need this functionality independently.
128
+
174
129
  ---
175
130
 
176
131
  ## 6. Unused Methods
@@ -250,15 +205,50 @@ end
250
205
  1. **Widget Matching Logic (#2)** - Most duplicated, used in many critical operations
251
206
  2. **/Annots Array Manipulation (#3)** - Complex logic that's error-prone when duplicated
252
207
 
253
- ### Medium Priority
254
- 3. **Page-Finding Logic (#1)** - Used in multiple places, but less frequently
255
- 4. **Box Parsing Logic (#4)** - Simple duplication, easy to refactor
256
208
 
257
209
  ### Low Priority
258
- 5. **next_fresh_object_number (#5)** - Simple duplication
259
- 6. **Object Reference Extraction (#8)** - Could improve consistency
260
- 7. **Unused Methods (#6)** - Cleanup task
261
- 8. **Base64 Decoding (#7)** - Minor duplication
210
+ 6. **next_fresh_object_number (#5)** - Simple duplication (may be intentional)
211
+ 7. **Object Reference Extraction (#8)** - Could improve consistency
212
+ 8. **Unused Methods (#6)** - Cleanup task (`get_widget_rect_dimensions`)
213
+ 9. **Base64 Decoding (#7)** - Minor duplication
214
+
215
+ ### Completed ✅
216
+ - **Page-Finding Logic (#1)** - Successfully refactored into `DictScan.is_page?` and unified page-finding methods
217
+ - **Checkbox Appearance Creation (#9)** - Extracted common Form XObject building logic into `build_form_xobject` helper method
218
+ - **Box Parsing Logic (#4)** - Extracted common box parsing logic into `DictScan.parse_box` helper method
219
+ - **PDF Metadata Formatting (#10)** - Moved `format_pdf_key` and `format_pdf_value` to `DictScan` module as shared utilities
220
+
221
+ ---
222
+
223
+ ## 9. Duplicated Checkbox Appearance Creation Logic ✅ **COMPLETED**
224
+
225
+ ### Status
226
+ **RESOLVED** - This refactoring has been completed:
227
+ - ✅ `AddField#build_form_xobject` exists (line 472 in add_field.rb)
228
+ - ✅ `AddField#create_checkbox_yes_appearance` now uses `build_form_xobject` (line 458)
229
+ - ✅ `AddField#create_checkbox_off_appearance` now uses `build_form_xobject` (line 469)
230
+
231
+ ### Original Issue
232
+ The `create_checkbox_yes_appearance` and `create_checkbox_off_appearance` methods had duplicated Form XObject dictionary building logic.
233
+
234
+ ### Resolution
235
+ Extracted the common Form XObject dictionary building logic into `build_form_xobject` helper method. Both checkbox appearance methods now use this shared method, reducing duplication while maintaining existing functionality.
236
+
237
+ ---
238
+
239
+ ## 10. PDF Metadata Formatting Methods Could Be Shared ✅ **COMPLETED**
240
+
241
+ ### Status
242
+ **RESOLVED** - This refactoring has been completed:
243
+ - ✅ `DictScan.format_pdf_key(key)` exists (line 134 in dict_scan.rb)
244
+ - ✅ `DictScan.format_pdf_value(value)` exists (line 140 in dict_scan.rb)
245
+ - ✅ `AddField` now uses `DictScan.format_pdf_key` and `DictScan.format_pdf_value` (lines 145-146, 195-196)
246
+
247
+ ### Original Issue
248
+ The `format_pdf_key` and `format_pdf_value` methods in `AddField` were useful utility functions that could be shared across the codebase.
249
+
250
+ ### Resolution
251
+ Moved `format_pdf_key` and `format_pdf_value` from `AddField` to the `DictScan` module as module functions. This makes them reusable throughout the codebase and provides a single source of truth for PDF formatting rules. `AddField` now uses these shared utilities, maintaining existing functionality while improving code reusability.
262
252
 
263
253
  ---
264
254
 
@@ -29,6 +29,10 @@ module AcroThat
29
29
  "/Tx"
30
30
  when :button, "button", "/Btn", "/btn"
31
31
  "/Btn"
32
+ when :radio, "radio"
33
+ "/Btn"
34
+ when :checkbox, "checkbox"
35
+ "/Btn"
32
36
  when :choice, "choice", "/Ch", "/ch"
33
37
  "/Ch"
34
38
  when :signature, "signature", "/Sig", "/sig"
@@ -38,6 +42,12 @@ module AcroThat
38
42
  end
39
43
  @field_value = @options[:value] || ""
40
44
 
45
+ # Auto-set radio button flags if type is :radio and flags not explicitly set
46
+ # Radio button flags: Radio (bit 15 = 32768) + NoToggleToOff (bit 14 = 16384) = 49152
47
+ if [:radio, "radio"].include?(type_input) && !(@metadata[:Ff] || @metadata["Ff"])
48
+ @metadata[:Ff] = 49_152
49
+ end
50
+
41
51
  # Create a proper field dictionary + a widget annotation that references it via /Parent
42
52
  @field_obj_num = next_fresh_object_number
43
53
  widget_obj_num = @field_obj_num + 1
@@ -74,6 +84,21 @@ module AcroThat
74
84
  end
75
85
  end
76
86
 
87
+ # If this is a checkbox (button field that's not a radio button), add appearance dictionaries
88
+ # Button fields can be checkboxes or radio buttons:
89
+ # - Radio buttons have Radio flag (bit 15 = 32768) set
90
+ # - Checkboxes don't have Radio flag set
91
+ is_checkbox = false
92
+ if @field_type == "/Btn"
93
+ field_flags = (@metadata[:Ff] || @metadata["Ff"] || 0).to_i
94
+ is_radio = field_flags.anybits?(32_768) || [:radio, "radio"].include?(type_input)
95
+ is_checkbox = !is_radio
96
+ end
97
+
98
+ if is_checkbox
99
+ add_checkbox_appearance(widget_obj_num, x, y, width, height)
100
+ end
101
+
77
102
  true
78
103
  end
79
104
 
@@ -85,13 +110,15 @@ module AcroThat
85
110
  dict += " /T #{DictScan.encode_pdf_string(@name)}\n"
86
111
 
87
112
  # Apply /Ff from metadata, or use default 0
113
+ # Note: Radio button flags should already be set in metadata during type normalization
88
114
  field_flags = @metadata[:Ff] || @metadata["Ff"] || 0
89
115
  dict += " /Ff #{field_flags}\n"
90
116
 
91
117
  dict += " /DA (/Helv 0 Tf 0 g)\n"
92
118
 
93
119
  # For signature fields with image data, don't set /V (appearance stream will be added separately)
94
- # For other fields or non-image signature values, set /V normally
120
+ # For checkboxes/radio buttons, set /V to normalized value (Yes/Off) - macOS Preview needs this
121
+ # For other fields, set /V normally
95
122
  should_set_value = if type == "/Sig" && value && !value.empty?
96
123
  # Check if value looks like image data
97
124
  !(value.is_a?(String) && (value.start_with?("data:image/") || (value.length > 50 && value.match?(%r{^[A-Za-z0-9+/]*={0,2}$}))))
@@ -99,14 +126,24 @@ module AcroThat
99
126
  true
100
127
  end
101
128
 
102
- dict += " /V #{DictScan.encode_pdf_string(value)}\n" if should_set_value && value && !value.empty?
129
+ # For button fields (checkboxes/radio), normalize value to "Yes" or "Off"
130
+ normalized_field_value = if type == "/Btn" && value
131
+ # Accept "Yes", "/Yes" (PDF name format), true (boolean), or "true" (string)
132
+ value_str = value.to_s
133
+ is_checked = ["Yes", "/Yes", "true"].include?(value_str) || value == true
134
+ is_checked ? "Yes" : "Off"
135
+ else
136
+ value
137
+ end
138
+
139
+ dict += " /V #{DictScan.encode_pdf_string(normalized_field_value)}\n" if should_set_value && normalized_field_value && !normalized_field_value.to_s.empty?
103
140
 
104
141
  # Apply other metadata entries (excluding Ff which we handled above)
105
142
  @metadata.each do |key, val|
106
143
  next if [:Ff, "Ff"].include?(key) # Already handled above
107
144
 
108
- pdf_key = format_pdf_key(key)
109
- pdf_value = format_pdf_value(val)
145
+ pdf_key = DictScan.format_pdf_key(key)
146
+ pdf_value = DictScan.format_pdf_value(val)
110
147
  dict += " #{pdf_key} #{pdf_value}\n"
111
148
  end
112
149
 
@@ -126,22 +163,37 @@ module AcroThat
126
163
  widget += " /F 4\n"
127
164
  widget += " /DA (/Helv 0 Tf 0 g)\n"
128
165
 
166
+ # For checkboxes, /V is set to "Yes" or "Off" and /AS is set accordingly
129
167
  # For signature fields with image data, don't set /V (appearance stream will be added separately)
130
168
  # For other fields or non-image signature values, set /V normally
131
169
  should_set_value = if type == "/Sig" && value && !value.empty?
132
170
  # Check if value looks like image data
133
171
  !(value.is_a?(String) && (value.start_with?("data:image/") || (value.length > 50 && value.match?(%r{^[A-Za-z0-9+/]*={0,2}$}))))
172
+ elsif type == "/Btn"
173
+ # For button fields (checkboxes), set /V to "Yes" or "Off"
174
+ # This will be handled by checkbox appearance code, but we set it here for consistency
175
+ true
134
176
  else
135
177
  true
136
178
  end
137
179
 
138
- widget += " /V #{DictScan.encode_pdf_string(value)}\n" if should_set_value && value && !value.empty?
180
+ # For checkboxes, set /V to "Yes" or empty/Off
181
+ if type == "/Btn" && should_set_value
182
+ # Checkbox value should be "Yes" if checked, otherwise empty or "Off"
183
+ # Accept "Yes", "/Yes" (PDF name format), true (boolean), or "true" (string)
184
+ value_str = value.to_s
185
+ is_checked = ["Yes", "/Yes", "true"].include?(value_str) || value == true
186
+ checkbox_value = is_checked ? "Yes" : "Off"
187
+ widget += " /V #{DictScan.encode_pdf_string(checkbox_value)}\n"
188
+ elsif should_set_value && value && !value.empty?
189
+ widget += " /V #{DictScan.encode_pdf_string(value)}\n"
190
+ end
139
191
 
140
192
  # Apply metadata entries that are valid for widgets
141
193
  # Common widget properties: /Q (alignment), /Ff (field flags), /BS (border style), etc.
142
194
  @metadata.each do |key, val|
143
- pdf_key = format_pdf_key(key)
144
- pdf_value = format_pdf_value(val)
195
+ pdf_key = DictScan.format_pdf_key(key)
196
+ pdf_value = DictScan.format_pdf_value(val)
145
197
  # Only add if not already present (we've added /F above, /V above if value exists)
146
198
  next if ["/F", "/V"].include?(pdf_key)
147
199
 
@@ -182,6 +234,41 @@ module AcroThat
182
234
  patched = DictScan.upsert_key_value(patched, "/NeedAppearances", "true")
183
235
  end
184
236
 
237
+ # Step 2.5: Remove /XFA if present (prevents XFA detection warnings in viewers like Master PDF)
238
+ # We're creating AcroForms, not XFA forms, so remove /XFA if it exists
239
+ if patched.include?("/XFA")
240
+ xfa_pattern = %r{/XFA(?=[\s(<\[/])}
241
+ if patched.match(xfa_pattern)
242
+ # Try to get the value token to determine what we're removing
243
+ xfa_value = DictScan.value_token_after("/XFA", patched)
244
+ if xfa_value
245
+ # Remove /XFA by replacing it with an empty string
246
+ # We'll use a simple approach: find the key and remove it with its value
247
+ xfa_match = patched.match(xfa_pattern)
248
+ if xfa_match
249
+ # Find the start and end of /XFA and its value
250
+ key_start = xfa_match.begin(0)
251
+ # Skip /XFA key
252
+ value_start = xfa_match.end(0)
253
+ value_start += 1 while value_start < patched.length && patched[value_start] =~ /\s/
254
+ # Use value_token_after to get the complete value token
255
+ # We already have xfa_value, so calculate its end
256
+ value_end = value_start + xfa_value.length
257
+ # Skip trailing whitespace
258
+ value_end += 1 while value_end < patched.length && patched[value_end] =~ /\s/
259
+ # Remove /XFA and its value
260
+ before = patched[0...key_start]
261
+ # Remove any whitespace before /XFA too (but not the opening <<)
262
+ before = before.rstrip
263
+ after = patched[value_end..]
264
+ patched = "#{before} #{after.lstrip}".strip
265
+ # Clean up any double spaces
266
+ patched = patched.gsub(/\s+/, " ")
267
+ end
268
+ end
269
+ end
270
+ end
271
+
185
272
  # Step 3: Ensure /DR /Font has /Helv mapping
186
273
  unless patched.include?("/DR") && patched.include?("/Helv")
187
274
  font_obj_num = next_fresh_object_number
@@ -281,40 +368,120 @@ module AcroThat
281
368
  true
282
369
  end
283
370
 
284
- # Format a metadata key as a PDF dictionary key (ensure it starts with /)
285
- def format_pdf_key(key)
286
- key_str = key.to_s
287
- key_str.start_with?("/") ? key_str : "/#{key_str}"
288
- end
289
-
290
- # Format a metadata value appropriately for PDF
291
- def format_pdf_value(value)
292
- case value
293
- when Integer, Float
294
- value.to_s
295
- when String
296
- # If it looks like a PDF string (starts with parenthesis or angle bracket), use as-is
297
- if value.start_with?("(") || value.start_with?("<") || value.start_with?("/")
298
- value
299
- else
300
- # Otherwise encode as a PDF string
301
- DictScan.encode_pdf_string(value)
371
+ def add_checkbox_appearance(widget_obj_num, _x, _y, width, height)
372
+ # Create appearance form XObjects for Yes and Off states
373
+ yes_obj_num = next_fresh_object_number
374
+ off_obj_num = yes_obj_num + 1
375
+
376
+ # Create Yes appearance (checked box with checkmark)
377
+ yes_body = create_checkbox_yes_appearance(width, height)
378
+ @document.instance_variable_get(:@patches) << { ref: [yes_obj_num, 0], body: yes_body }
379
+
380
+ # Create Off appearance (empty box)
381
+ off_body = create_checkbox_off_appearance(width, height)
382
+ @document.instance_variable_get(:@patches) << { ref: [off_obj_num, 0], body: off_body }
383
+
384
+ # Get current widget body and add /AP dictionary
385
+ widget_ref = [widget_obj_num, 0]
386
+ original_widget_body = get_object_body_with_patch(widget_ref)
387
+ widget_body = original_widget_body.dup
388
+
389
+ # Create /AP dictionary with Yes and Off appearances
390
+ ap_dict = "<<\n /N <<\n /Yes #{yes_obj_num} 0 R\n /Off #{off_obj_num} 0 R\n >>\n>>"
391
+
392
+ # Add /AP to widget
393
+ if widget_body.include?("/AP")
394
+ # Replace existing /AP
395
+ ap_key_pattern = %r{/AP(?=[\s(<\[/])}
396
+ if widget_body.match(ap_key_pattern)
397
+ widget_body = DictScan.replace_key_value(widget_body, "/AP", ap_dict)
302
398
  end
303
- when Array
304
- # Array format: [item1 item2 item3]
305
- items = value.map { |v| format_pdf_value(v) }.join(" ")
306
- "[#{items}]"
307
- when Hash
308
- # Dictionary format: << /Key1 value1 /Key2 value2 >>
309
- dict = value.map do |k, v|
310
- pdf_key = format_pdf_key(k)
311
- pdf_val = format_pdf_value(v)
312
- " #{pdf_key} #{pdf_val}"
313
- end.join("\n")
314
- "<<\n#{dict}\n>>"
315
399
  else
316
- value.to_s
400
+ # Insert /AP before closing >>
401
+ widget_body = DictScan.upsert_key_value(widget_body, "/AP", ap_dict)
317
402
  end
403
+
404
+ # Set /AS based on the value - use the EXACT same normalization logic as widget creation
405
+ # This ensures consistency between /V and /AS
406
+ # Normalize value: "Yes" if truthy (Yes, "/Yes", true, etc.), otherwise "Off"
407
+ value_str = @field_value.to_s
408
+ is_checked = value_str == "Yes" || value_str == "/Yes" || value_str == "true" || @field_value == true
409
+ normalized_value = is_checked ? "Yes" : "Off"
410
+
411
+ # Set /AS to match normalized value (same as what was set for /V in widget creation)
412
+ as_value = if normalized_value == "Yes"
413
+ "/Yes"
414
+ else
415
+ "/Off"
416
+ end
417
+
418
+ widget_body = if widget_body.include?("/AS")
419
+ DictScan.replace_key_value(widget_body, "/AS", as_value)
420
+ else
421
+ DictScan.upsert_key_value(widget_body, "/AS", as_value)
422
+ end
423
+
424
+ apply_patch(widget_ref, widget_body, original_widget_body)
425
+ end
426
+
427
+ def create_checkbox_yes_appearance(width, height)
428
+ # Create a form XObject that draws a checked checkbox
429
+ # Box outline + checkmark
430
+ # Scale to match width and height
431
+ # Simple appearance: draw a box and a checkmark
432
+ # For simplicity, use PDF drawing operators
433
+ # Box: rectangle from (0,0) to (width, height)
434
+ # Checkmark: simple path drawing
435
+
436
+ # PDF content stream for checked checkbox
437
+ # Draw just the checkmark (no box border)
438
+ border_width = [width * 0.08, height * 0.08].min
439
+
440
+ # Calculate checkmark path
441
+ check_x1 = width * 0.25
442
+ check_y1 = height * 0.45
443
+ check_x2 = width * 0.45
444
+ check_y2 = height * 0.25
445
+ check_x3 = width * 0.75
446
+ check_y3 = height * 0.75
447
+
448
+ content_stream = "q\n"
449
+ content_stream += "0 0 0 rg\n" # Black color (darker)
450
+ content_stream += "#{border_width} w\n" # Line width
451
+ # Draw checkmark only (no box border)
452
+ content_stream += "#{check_x1} #{check_y1} m\n"
453
+ content_stream += "#{check_x2} #{check_y2} l\n"
454
+ content_stream += "#{check_x3} #{check_y3} l\n"
455
+ content_stream += "S\n" # Stroke
456
+ content_stream += "Q\n"
457
+
458
+ build_form_xobject(content_stream, width, height)
459
+ end
460
+
461
+ def create_checkbox_off_appearance(width, height)
462
+ # Create a form XObject for unchecked checkbox
463
+ # Empty appearance (no border, no checkmark) - viewer will draw default checkbox
464
+
465
+ content_stream = "q\n"
466
+ # Empty appearance for unchecked state
467
+ content_stream += "Q\n"
468
+
469
+ build_form_xobject(content_stream, width, height)
470
+ end
471
+
472
+ def build_form_xobject(content_stream, width, height)
473
+ # Build a Form XObject dictionary with the given content stream
474
+ dict = "<<\n"
475
+ dict += " /Type /XObject\n"
476
+ dict += " /Subtype /Form\n"
477
+ dict += " /BBox [0 0 #{width} #{height}]\n"
478
+ dict += " /Length #{content_stream.bytesize}\n"
479
+ dict += ">>\n"
480
+ dict += "stream\n"
481
+ dict += content_stream
482
+ dict += "\nendstream"
483
+
484
+ dict
318
485
  end
319
486
  end
320
487
  end