acro_that 0.1.4 → 0.1.6

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40016774a590828e59c4c9ea4331a8d170ccd536d7522cbdb193fefda3f28333
4
- data.tar.gz: bade6f113359d96d1f7c1a85efd7033732350c0b19bf40dd412e104da359aa5a
3
+ metadata.gz: '09e4943697402d884588b9cb40c2d8b3fb010809e9b9f5781711b600eeeabc74'
4
+ data.tar.gz: a5c950e1c0fad9555314c5bc08d85a493626a0e174072990fff17a86ef32f61c
5
5
  SHA512:
6
- metadata.gz: 60e6c6afd93cfd8911c0b0a0d4b6b080cb2c8af85a104cf3fd09f8fc7f9ac642999a8d5694c374ad64b71d4aa769f6d8b390cc82829317abd64598aa42e7280f
7
- data.tar.gz: 31762cf9f6f285edd78976692e9c5bab7a67cf6298a03c8d4208b02927639be29e0d1f4ca1f14c3f1c170b9f617404e0931dc8bbedd17de79fcee5c80035dd29
6
+ metadata.gz: dafc26f93d0101eea028176451f1450bf5c536ee3657cf2c55bb151cdd282b85c0428e876bae4495c9bb9a36f4255128cc5fba938a7fb7833b0888ada5697ea8
7
+ data.tar.gz: 6ee3232b237cdeb6f61c7d73b43599b187a575d25e08a522020df52edc9224e2400e5372c08923ecf29c2e6332ee77e51cb828beb0b74974bd8079533ccc752e
data/CHANGELOG.md CHANGED
@@ -5,7 +5,15 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.1.3] - 2025-01-XX
8
+ ## [0.1.5] - 2025-11-01
9
+
10
+ ### Fixed
11
+ - Fixed signature field image data parsing when adding signature fields. Image data (base64 or data URI) is now properly detected and parsed when creating signature fields, matching the behavior of `update_field`.
12
+
13
+ ### Added
14
+ - Added support for `metadata` option in `add_field` to pass PDF widget properties. This allows setting properties like field flags (`Ff`) for multiline text fields, alignment (`Q`), and other PDF widget options directly when creating fields.
15
+
16
+ ## [0.1.4] - 2025-11-01
9
17
 
10
18
  ### Fixed
11
19
  - Fixed bug where fields added to multi-page PDFs were all placed on the same page. Fields now correctly appear on their specified pages when using the `page` option in `add_field`.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- acro_that (0.1.2)
4
+ acro_that (0.1.5)
5
5
  chunky_png (~> 1.4)
6
6
 
7
7
  GEM
data/issues/README.md CHANGED
@@ -13,21 +13,26 @@ This folder contains documentation of code cleanup and refactoring opportunities
13
13
  2. **/Annots Array Manipulation** - Complex logic duplicated in 3 locations
14
14
 
15
15
  ### Medium Priority Issues
16
- 3. **Page-Finding Logic** - Similar logic in 4+ methods
17
- 4. **Box Parsing Logic** - Repeated code blocks for 5 box types
16
+ 3. **Box Parsing Logic** - Repeated code blocks for 5 box types
17
+ 4. **Checkbox Appearance Creation** - Significant duplication in new code
18
+ 5. **PDF Metadata Formatting** - Could benefit from being shared utilities
18
19
 
19
20
  ### Low Priority Issues
20
- 5. Duplicated `next_fresh_object_number` implementation
21
- 6. Object reference extraction pattern duplication
22
- 7. Unused method: `get_widget_rect_dimensions`
23
- 8. Base64 decoding logic duplication
21
+ 6. Duplicated `next_fresh_object_number` implementation (may be intentional)
22
+ 7. Object reference extraction pattern duplication
23
+ 8. Unused method: `get_widget_rect_dimensions`
24
+ 9. Base64 decoding logic duplication
25
+
26
+ ### Completed ✅
27
+ - **Page-Finding Logic** - Successfully refactored into `DictScan.is_page?` and unified page-finding methods
24
28
 
25
29
  ## Quick Stats
26
30
 
27
- - **8 refactoring opportunities** identified
31
+ - **10 refactoring opportunities** identified (1 completed, 9 remaining)
28
32
  - **6+ locations** with widget matching duplication
29
33
  - **3 locations** with /Annots array manipulation duplication
30
34
  - **1 unused method** found
35
+ - **2 new issues** identified in recent code additions
31
36
 
32
37
  ## Next Steps
33
38
 
@@ -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
 
@@ -12,6 +12,7 @@ module AcroThat
12
12
  @document = document
13
13
  @name = name
14
14
  @options = options
15
+ @metadata = options[:metadata] || {}
15
16
  end
16
17
 
17
18
  def call
@@ -28,6 +29,10 @@ module AcroThat
28
29
  "/Tx"
29
30
  when :button, "button", "/Btn", "/btn"
30
31
  "/Btn"
32
+ when :radio, "radio"
33
+ "/Btn"
34
+ when :checkbox, "checkbox"
35
+ "/Btn"
31
36
  when :choice, "choice", "/Ch", "/ch"
32
37
  "/Ch"
33
38
  when :signature, "signature", "/Sig", "/sig"
@@ -37,6 +42,12 @@ module AcroThat
37
42
  end
38
43
  @field_value = @options[:value] || ""
39
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
+
40
51
  # Create a proper field dictionary + a widget annotation that references it via /Parent
41
52
  @field_obj_num = next_fresh_object_number
42
53
  widget_obj_num = @field_obj_num + 1
@@ -60,6 +71,34 @@ module AcroThat
60
71
  # Add widget to the target page's /Annots
61
72
  add_widget_to_page(widget_obj_num, page_num)
62
73
 
74
+ # If this is a signature field with image data, add the signature appearance
75
+ if @field_type == "/Sig" && @field_value && !@field_value.empty?
76
+ image_data = @field_value
77
+ # Check if value looks like base64 image data or data URI (same logic as update_field)
78
+ if 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}$})))
79
+ field_ref = [@field_obj_num, 0]
80
+ # Try adding signature appearance - use width and height from options
81
+ action = Actions::AddSignatureAppearance.new(@document, field_ref, image_data, width: width, height: height)
82
+ # NOTE: We don't fail if appearance addition fails - field was still created successfully
83
+ action.call
84
+ end
85
+ end
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
+
63
102
  true
64
103
  end
65
104
 
@@ -69,9 +108,45 @@ module AcroThat
69
108
  dict = "<<\n"
70
109
  dict += " /FT #{type}\n"
71
110
  dict += " /T #{DictScan.encode_pdf_string(@name)}\n"
72
- dict += " /Ff 0\n"
111
+
112
+ # Apply /Ff from metadata, or use default 0
113
+ # Note: Radio button flags should already be set in metadata during type normalization
114
+ field_flags = @metadata[:Ff] || @metadata["Ff"] || 0
115
+ dict += " /Ff #{field_flags}\n"
116
+
73
117
  dict += " /DA (/Helv 0 Tf 0 g)\n"
74
- dict += " /V #{DictScan.encode_pdf_string(value)}\n" if value && !value.empty?
118
+
119
+ # For signature fields with image data, don't set /V (appearance stream will be added separately)
120
+ # For checkboxes/radio buttons, set /V to normalized value (Yes/Off) - macOS Preview needs this
121
+ # For other fields, set /V normally
122
+ should_set_value = if type == "/Sig" && value && !value.empty?
123
+ # Check if value looks like image data
124
+ !(value.is_a?(String) && (value.start_with?("data:image/") || (value.length > 50 && value.match?(%r{^[A-Za-z0-9+/]*={0,2}$}))))
125
+ else
126
+ true
127
+ end
128
+
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?
140
+
141
+ # Apply other metadata entries (excluding Ff which we handled above)
142
+ @metadata.each do |key, val|
143
+ next if [:Ff, "Ff"].include?(key) # Already handled above
144
+
145
+ pdf_key = DictScan.format_pdf_key(key)
146
+ pdf_value = DictScan.format_pdf_value(val)
147
+ dict += " #{pdf_key} #{pdf_value}\n"
148
+ end
149
+
75
150
  dict += ">>"
76
151
  dict
77
152
  end
@@ -87,7 +162,44 @@ module AcroThat
87
162
  widget += " /Rect #{rect_array}\n"
88
163
  widget += " /F 4\n"
89
164
  widget += " /DA (/Helv 0 Tf 0 g)\n"
90
- widget += " /V #{DictScan.encode_pdf_string(value)}\n" if value && !value.empty?
165
+
166
+ # For checkboxes, /V is set to "Yes" or "Off" and /AS is set accordingly
167
+ # For signature fields with image data, don't set /V (appearance stream will be added separately)
168
+ # For other fields or non-image signature values, set /V normally
169
+ should_set_value = if type == "/Sig" && value && !value.empty?
170
+ # Check if value looks like image data
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
176
+ else
177
+ true
178
+ end
179
+
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
191
+
192
+ # Apply metadata entries that are valid for widgets
193
+ # Common widget properties: /Q (alignment), /Ff (field flags), /BS (border style), etc.
194
+ @metadata.each do |key, val|
195
+ pdf_key = DictScan.format_pdf_key(key)
196
+ pdf_value = DictScan.format_pdf_value(val)
197
+ # Only add if not already present (we've added /F above, /V above if value exists)
198
+ next if ["/F", "/V"].include?(pdf_key)
199
+
200
+ widget += " #{pdf_key} #{pdf_value}\n"
201
+ end
202
+
91
203
  widget += ">>"
92
204
  widget
93
205
  end
@@ -122,6 +234,41 @@ module AcroThat
122
234
  patched = DictScan.upsert_key_value(patched, "/NeedAppearances", "true")
123
235
  end
124
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
+
125
272
  # Step 3: Ensure /DR /Font has /Helv mapping
126
273
  unless patched.include?("/DR") && patched.include?("/Helv")
127
274
  font_obj_num = next_fresh_object_number
@@ -220,6 +367,122 @@ module AcroThat
220
367
  apply_patch(target_page_ref, new_body, page_body) if new_body && new_body != page_body
221
368
  true
222
369
  end
370
+
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)
398
+ end
399
+ else
400
+ # Insert /AP before closing >>
401
+ widget_body = DictScan.upsert_key_value(widget_body, "/AP", ap_dict)
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
485
+ end
223
486
  end
224
487
  end
225
488
  end
@@ -129,6 +129,36 @@ module AcroThat
129
129
  # Also update any widget annotations that reference this field via /Parent
130
130
  update_widget_annotations_for_field(field_ref, @new_value)
131
131
 
132
+ # If this is a checkbox without appearance streams, create them
133
+ if fld.button_field?
134
+ # Check if it's a checkbox (not a radio button) by checking field flags
135
+ field_body = get_object_body_with_patch(field_ref)
136
+ is_radio = false
137
+ if field_body
138
+ field_flags_match = field_body.match(%r{/Ff\s+(\d+)})
139
+ if field_flags_match
140
+ field_flags = field_flags_match[1].to_i
141
+ # Radio button flag is bit 15 = 32768
142
+ is_radio = field_flags.anybits?(32_768)
143
+ end
144
+ end
145
+
146
+ # Only create checkbox appearances (not radio buttons)
147
+ unless is_radio
148
+ widget_ref = find_checkbox_widget(fld.ref)
149
+ if widget_ref
150
+ widget_body = get_object_body_with_patch(widget_ref)
151
+ # Create appearances if /AP doesn't exist
152
+ unless widget_body&.include?("/AP")
153
+ rect = extract_widget_rect(widget_body)
154
+ if rect && rect[:width].positive? && rect[:height].positive?
155
+ add_checkbox_appearance(widget_ref, rect[:width], rect[:height])
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+
132
162
  # Best-effort: set NeedAppearances to true so viewers regenerate appearances
133
163
  ensure_need_appearances
134
164
 
@@ -144,8 +174,22 @@ module AcroThat
144
174
  # Ensure we have a valid dictionary
145
175
  return dict_body unless dict_body&.include?("<<")
146
176
 
147
- # Encode the new value
148
- v_token = DictScan.encode_pdf_string(new_value)
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
+ normalized_value = if is_button_field
182
+ # For checkboxes/radio buttons, normalize to "Yes" or "Off"
183
+ # Accept "Yes", "/Yes" (PDF name format), true (boolean), or "true" (string)
184
+ value_str = new_value.to_s
185
+ is_checked = ["Yes", "/Yes", "true"].include?(value_str) || new_value == true
186
+ is_checked ? "Yes" : "Off"
187
+ else
188
+ new_value
189
+ end
190
+
191
+ # Encode the normalized value
192
+ v_token = DictScan.encode_pdf_string(normalized_value)
149
193
 
150
194
  # Find /V using pattern matching to ensure we get the complete key
151
195
  v_key_pattern = %r{/V(?=[\s(<\[/])}
@@ -166,24 +210,34 @@ module AcroThat
166
210
 
167
211
  # Update /AS for checkboxes/radio buttons if needed
168
212
  # Check for /FT /Btn more carefully
169
- ft_pattern = %r{/FT\s+/Btn}
170
- if ft_pattern.match(patched) && (as_needed = DictScan.appearance_choice_for(new_value, patched))
171
- as_pattern = %r{/AS(?=[\s(<\[/])}
172
- has_as = patched.match(as_pattern)
173
-
174
- patched = if has_as
175
- DictScan.replace_key_value(patched, "/AS", as_needed)
176
- else
177
- DictScan.upsert_key_value(patched, "/AS", as_needed)
178
- end
179
-
180
- # Verify /AS replacement worked
181
- unless patched && patched.include?("<<") && patched.include?(">>")
182
- warn "Warning: Dictionary corrupted after /AS replacement"
183
- # Revert to before /AS change
184
- return DictScan.replace_key_value(dict_body, "/V", v_token) if has_v
185
-
186
- return dict_body
213
+ if ft_pattern.match(patched)
214
+ # For button fields, set /AS based on normalized value
215
+ as_value = if normalized_value == "Yes"
216
+ "/Yes"
217
+ else
218
+ "/Off"
219
+ end
220
+
221
+ # Only set /AS if /AP exists (appearance dictionary is present)
222
+ # If /AP doesn't exist, we can't set /AS properly
223
+ if patched.include?("/AP")
224
+ as_pattern = %r{/AS(?=[\s(<\[/])}
225
+ has_as = patched.match(as_pattern)
226
+
227
+ patched = if has_as
228
+ DictScan.replace_key_value(patched, "/AS", as_value)
229
+ else
230
+ DictScan.upsert_key_value(patched, "/AS", as_value)
231
+ end
232
+
233
+ # Verify /AS replacement worked
234
+ unless patched && patched.include?("<<") && patched.include?(">>")
235
+ warn "Warning: Dictionary corrupted after /AS replacement"
236
+ # Revert to before /AS change
237
+ return DictScan.replace_key_value(dict_body, "/V", v_token) if has_v
238
+
239
+ return dict_body
240
+ end
187
241
  end
188
242
  end
189
243
 
@@ -296,6 +350,184 @@ module AcroThat
296
350
 
297
351
  DictScan.is_multiline_field?(field_body)
298
352
  end
353
+
354
+ def find_checkbox_widget(field_ref)
355
+ # Check patches first
356
+ patches = @document.instance_variable_get(:@patches)
357
+ patches.each do |patch|
358
+ next unless patch[:body]
359
+ next unless DictScan.is_widget?(patch[:body])
360
+
361
+ # Check if widget has /Parent pointing to field_ref
362
+ if patch[:body] =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R}
363
+ parent_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
364
+ return patch[:ref] if parent_ref == field_ref
365
+ end
366
+
367
+ # Also check if widget IS the field (flat structure)
368
+ if patch[:body].include?("/FT") && DictScan.value_token_after("/FT",
369
+ patch[:body]) == "/Btn" && (patch[:ref] == field_ref)
370
+ return patch[:ref]
371
+ end
372
+ end
373
+
374
+ # Then check resolver (for existing widgets)
375
+ resolver.each_object do |ref, body|
376
+ next unless body && DictScan.is_widget?(body)
377
+
378
+ # Check if widget has /Parent pointing to field_ref
379
+ if body =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R}
380
+ parent_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
381
+ return ref if parent_ref == field_ref
382
+ end
383
+
384
+ # Also check if widget IS the field (flat structure)
385
+ if body.include?("/FT") && DictScan.value_token_after("/FT", body) == "/Btn" && (ref == field_ref)
386
+ return ref
387
+ end
388
+ end
389
+
390
+ # Fallback: if field_ref itself is a widget
391
+ body = get_object_body_with_patch(field_ref)
392
+ return field_ref if body && DictScan.is_widget?(body) && body.include?("/FT") && DictScan.value_token_after(
393
+ "/FT", body
394
+ ) == "/Btn"
395
+
396
+ nil
397
+ end
398
+
399
+ def extract_widget_rect(widget_body)
400
+ return nil unless widget_body
401
+
402
+ rect_tok = DictScan.value_token_after("/Rect", widget_body)
403
+ return nil unless rect_tok&.start_with?("[")
404
+
405
+ rect_values = rect_tok.scan(/[-+]?\d*\.?\d+/).map(&:to_f)
406
+ return nil unless rect_values.length == 4
407
+
408
+ x1, y1, x2, y2 = rect_values
409
+ width = (x2 - x1).abs
410
+ height = (y2 - y1).abs
411
+
412
+ return nil if width <= 0 || height <= 0
413
+
414
+ { x: x1, y: y1, width: width, height: height }
415
+ end
416
+
417
+ def add_checkbox_appearance(widget_ref, width, height)
418
+ # Create appearance form XObjects for Yes and Off states
419
+ yes_obj_num = next_fresh_object_number
420
+ off_obj_num = yes_obj_num + 1
421
+
422
+ # Create Yes appearance (checked box with checkmark)
423
+ yes_body = create_checkbox_yes_appearance(width, height)
424
+ @document.instance_variable_get(:@patches) << { ref: [yes_obj_num, 0], body: yes_body }
425
+
426
+ # Create Off appearance (empty box)
427
+ off_body = create_checkbox_off_appearance(width, height)
428
+ @document.instance_variable_get(:@patches) << { ref: [off_obj_num, 0], body: off_body }
429
+
430
+ # Get current widget body and add /AP dictionary
431
+ original_widget_body = get_object_body_with_patch(widget_ref)
432
+ widget_body = original_widget_body.dup
433
+
434
+ # Create /AP dictionary with Yes and Off appearances
435
+ ap_dict = "<<\n /N <<\n /Yes #{yes_obj_num} 0 R\n /Off #{off_obj_num} 0 R\n >>\n>>"
436
+
437
+ # Add /AP to widget
438
+ if widget_body.include?("/AP")
439
+ # Replace existing /AP
440
+ ap_key_pattern = %r{/AP(?=[\s(<\[/])}
441
+ if widget_body.match(ap_key_pattern)
442
+ widget_body = DictScan.replace_key_value(widget_body, "/AP", ap_dict)
443
+ end
444
+ else
445
+ # Insert /AP before closing >>
446
+ widget_body = DictScan.upsert_key_value(widget_body, "/AP", ap_dict)
447
+ end
448
+
449
+ # Set /AS based on the value - use the EXACT same normalization logic as widget creation
450
+ # This ensures consistency between /V and /AS
451
+ # Normalize value: "Yes" if truthy (Yes, "/Yes", true, etc.), otherwise "Off"
452
+ value_str = @new_value.to_s
453
+ is_checked = value_str == "Yes" || value_str == "/Yes" || value_str == "true" || @new_value == true
454
+ normalized_value = is_checked ? "Yes" : "Off"
455
+
456
+ # Set /AS to match normalized value (same as what was set for /V in widget creation)
457
+ as_value = if normalized_value == "Yes"
458
+ "/Yes"
459
+ else
460
+ "/Off"
461
+ end
462
+
463
+ widget_body = if widget_body.include?("/AS")
464
+ DictScan.replace_key_value(widget_body, "/AS", as_value)
465
+ else
466
+ DictScan.upsert_key_value(widget_body, "/AS", as_value)
467
+ end
468
+
469
+ apply_patch(widget_ref, widget_body, original_widget_body)
470
+ end
471
+
472
+ def create_checkbox_yes_appearance(width, height)
473
+ # Create a form XObject that draws a checked checkbox
474
+ # Box outline + checkmark
475
+ # Scale to match width and height
476
+ # Simple appearance: draw a box and a checkmark
477
+ # For simplicity, use PDF drawing operators
478
+ # Box: rectangle from (0,0) to (width, height)
479
+ # Checkmark: simple path drawing
480
+
481
+ # PDF content stream for checked checkbox
482
+ # Draw just the checkmark (no box border)
483
+ border_width = [width * 0.08, height * 0.08].min
484
+
485
+ # Calculate checkmark path
486
+ check_x1 = width * 0.25
487
+ check_y1 = height * 0.45
488
+ check_x2 = width * 0.45
489
+ check_y2 = height * 0.25
490
+ check_x3 = width * 0.75
491
+ check_y3 = height * 0.75
492
+
493
+ content_stream = "q\n"
494
+ content_stream += "0 0 0 rg\n" # Black color (darker)
495
+ content_stream += "#{border_width} w\n" # Line width
496
+ # Draw checkmark only (no box border)
497
+ content_stream += "#{check_x1} #{check_y1} m\n"
498
+ content_stream += "#{check_x2} #{check_y2} l\n"
499
+ content_stream += "#{check_x3} #{check_y3} l\n"
500
+ content_stream += "S\n" # Stroke
501
+ content_stream += "Q\n"
502
+
503
+ build_form_xobject(content_stream, width, height)
504
+ end
505
+
506
+ def create_checkbox_off_appearance(width, height)
507
+ # Create a form XObject for unchecked checkbox
508
+ # Empty appearance (no border, no checkmark) - viewer will draw default checkbox
509
+
510
+ content_stream = "q\n"
511
+ # Empty appearance for unchecked state
512
+ content_stream += "Q\n"
513
+
514
+ build_form_xobject(content_stream, width, height)
515
+ end
516
+
517
+ def build_form_xobject(content_stream, width, height)
518
+ # Build a Form XObject dictionary with the given content stream
519
+ dict = "<<\n"
520
+ dict += " /Type /XObject\n"
521
+ dict += " /Subtype /Form\n"
522
+ dict += " /BBox [0 0 #{width} #{height}]\n"
523
+ dict += " /Length #{content_stream.bytesize}\n"
524
+ dict += ">>\n"
525
+ dict += "stream\n"
526
+ dict += content_stream
527
+ dict += "\nendstream"
528
+
529
+ dict
530
+ end
299
531
  end
300
532
  end
301
533
  end
@@ -130,6 +130,42 @@ module AcroThat
130
130
  end
131
131
  end
132
132
 
133
+ # Format a metadata key as a PDF dictionary key (ensure it starts with /)
134
+ def format_pdf_key(key)
135
+ key_str = key.to_s
136
+ key_str.start_with?("/") ? key_str : "/#{key_str}"
137
+ end
138
+
139
+ # Format a metadata value appropriately for PDF
140
+ def format_pdf_value(value)
141
+ case value
142
+ when Integer, Float
143
+ value.to_s
144
+ when String
145
+ # If it looks like a PDF string (starts with parenthesis or angle bracket), use as-is
146
+ if value.start_with?("(") || value.start_with?("<") || value.start_with?("/")
147
+ value
148
+ else
149
+ # Otherwise encode as a PDF string
150
+ encode_pdf_string(value)
151
+ end
152
+ when Array
153
+ # Array format: [item1 item2 item3]
154
+ items = value.map { |v| format_pdf_value(v) }.join(" ")
155
+ "[#{items}]"
156
+ when Hash
157
+ # Dictionary format: << /Key1 value1 /Key2 value2 >>
158
+ dict = value.map do |k, v|
159
+ pdf_key = format_pdf_key(k)
160
+ pdf_val = format_pdf_value(v)
161
+ " #{pdf_key} #{pdf_val}"
162
+ end.join("\n")
163
+ "<<\n#{dict}\n>>"
164
+ else
165
+ value.to_s
166
+ end
167
+ end
168
+
133
169
  def value_token_after(key, dict_src)
134
170
  # Find key followed by delimiter (whitespace, (, <, [, /)
135
171
  # Use regex to ensure key is a complete token
@@ -335,6 +371,19 @@ module AcroThat
335
371
  ff_value.anybits?(0x1000)
336
372
  end
337
373
 
374
+ # Parse a box array (MediaBox, CropBox, ArtBox, BleedBox, TrimBox, etc.)
375
+ # Returns a hash with keys :llx, :lly, :urx, :ury, or nil if not found/invalid
376
+ def parse_box(body, box_type)
377
+ pattern = %r{/#{box_type}\s*\[(.*?)\]}
378
+ return nil unless body =~ pattern
379
+
380
+ box_values = ::Regexp.last_match(1).scan(/[-+]?\d*\.?\d+/).map(&:to_f)
381
+ return nil unless box_values.length == 4
382
+
383
+ llx, lly, urx, ury = box_values
384
+ { llx: llx, lly: lly, urx: urx, ury: ury }
385
+ end
386
+
338
387
  # Remove /AP (appearance stream) entry from a dictionary
339
388
  def remove_appearance_stream(dict_body)
340
389
  return dict_body unless dict_body&.include?("/AP")
@@ -84,58 +84,19 @@ module AcroThat
84
84
  # Extract MediaBox, CropBox, or ArtBox for dimensions
85
85
  width = nil
86
86
  height = nil
87
- media_box = nil
88
- crop_box = nil
89
- art_box = nil
90
- bleed_box = nil
91
- trim_box = nil
92
-
93
- # Try MediaBox first (most common)
94
- if body =~ %r{/MediaBox\s*\[(.*?)\]}
95
- box_values = ::Regexp.last_match(1).scan(/[-+]?\d*\.?\d+/).map(&:to_f)
96
- if box_values.length == 4
97
- llx, lly, urx, ury = box_values
98
- width = urx - llx
99
- height = ury - lly
100
- media_box = { llx: llx, lly: lly, urx: urx, ury: ury }
101
- end
102
- end
103
-
104
- # Try CropBox
105
- if body =~ %r{/CropBox\s*\[(.*?)\]}
106
- box_values = ::Regexp.last_match(1).scan(/[-+]?\d*\.?\d+/).map(&:to_f)
107
- if box_values.length == 4
108
- llx, lly, urx, ury = box_values
109
- crop_box = { llx: llx, lly: lly, urx: urx, ury: ury }
110
- end
111
- end
112
-
113
- # Try ArtBox
114
- if body =~ %r{/ArtBox\s*\[(.*?)\]}
115
- box_values = ::Regexp.last_match(1).scan(/[-+]?\d*\.?\d+/).map(&:to_f)
116
- if box_values.length == 4
117
- llx, lly, urx, ury = box_values
118
- art_box = { llx: llx, lly: lly, urx: urx, ury: ury }
119
- end
120
- end
121
87
 
122
- # Try BleedBox
123
- if body =~ %r{/BleedBox\s*\[(.*?)\]}
124
- box_values = ::Regexp.last_match(1).scan(/[-+]?\d*\.?\d+/).map(&:to_f)
125
- if box_values.length == 4
126
- llx, lly, urx, ury = box_values
127
- bleed_box = { llx: llx, lly: lly, urx: urx, ury: ury }
128
- end
88
+ # Try MediaBox first (most common) - also extract width/height
89
+ media_box = DictScan.parse_box(body, "MediaBox")
90
+ if media_box
91
+ width = media_box[:urx] - media_box[:llx]
92
+ height = media_box[:ury] - media_box[:lly]
129
93
  end
130
94
 
131
- # Try TrimBox
132
- if body =~ %r{/TrimBox\s*\[(.*?)\]}
133
- box_values = ::Regexp.last_match(1).scan(/[-+]?\d*\.?\d+/).map(&:to_f)
134
- if box_values.length == 4
135
- llx, lly, urx, ury = box_values
136
- trim_box = { llx: llx, lly: lly, urx: urx, ury: ury }
137
- end
138
- end
95
+ # Parse other box types
96
+ crop_box = DictScan.parse_box(body, "CropBox")
97
+ art_box = DictScan.parse_box(body, "ArtBox")
98
+ bleed_box = DictScan.parse_box(body, "BleedBox")
99
+ trim_box = DictScan.parse_box(body, "TrimBox")
139
100
 
140
101
  # Extract rotation
141
102
  rotate = nil
@@ -203,7 +164,7 @@ module AcroThat
203
164
  next unless body
204
165
 
205
166
  is_widget = DictScan.is_widget?(body)
206
-
167
+
207
168
  # Collect widget information if this is a widget
208
169
  if is_widget
209
170
  # Extract position from widget
@@ -266,6 +227,11 @@ module AcroThat
266
227
  ft_tok = body.include?("/FT") ? DictScan.value_token_after("/FT", body) : nil
267
228
  type = ft_tok
268
229
 
230
+ # Normalize button field values: "Yes" -> "/Yes" to match PDF name conventions
231
+ if type == "/Btn" && value == "Yes"
232
+ value = "/Yes"
233
+ end
234
+
269
235
  position = {}
270
236
  if is_widget
271
237
  rect_tok = DictScan.value_token_after("/Rect", body)
@@ -9,6 +9,8 @@ module AcroThat
9
9
  TYPES = {
10
10
  text: "/Tx",
11
11
  button: "/Btn",
12
+ checkbox: "/Btn",
13
+ radio: "/Btn",
12
14
  choice: "/Ch",
13
15
  signature: "/Sig"
14
16
  }.freeze
@@ -88,4 +88,3 @@ module AcroThat
88
88
  end
89
89
  end
90
90
  end
91
-
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AcroThat
4
- VERSION = "0.1.4"
4
+ VERSION = "0.1.6"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acro_that
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Wynkoop