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.
- checksums.yaml +4 -4
- data/.gitignore +3 -1
- data/Gemfile.lock +1 -1
- data/issues/README.md +32 -11
- data/issues/memory-benchmark-results.md +551 -0
- data/issues/memory-improvements.md +388 -0
- data/issues/memory-optimization-summary.md +204 -0
- data/issues/refactoring-opportunities.md +70 -80
- data/lib/acro_that/actions/add_field.rb +205 -38
- data/lib/acro_that/actions/update_field.rb +252 -20
- data/lib/acro_that/dict_scan.rb +49 -0
- data/lib/acro_that/document.rb +22 -53
- data/lib/acro_that/field.rb +2 -0
- data/lib/acro_that/incremental_writer.rb +3 -2
- data/lib/acro_that/object_resolver.rb +5 -0
- data/lib/acro_that/version.rb +1 -1
- metadata +5 -3
- data/.DS_Store +0 -0
|
@@ -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
|
-
###
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
- `Document#
|
|
12
|
-
- `
|
|
13
|
-
- `
|
|
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
|
-
###
|
|
20
|
-
|
|
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
|
-
###
|
|
26
|
-
-
|
|
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
|
-
###
|
|
103
|
-
|
|
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
|
-
###
|
|
106
|
-
|
|
98
|
+
### Original Issue
|
|
99
|
+
`Document#list_pages` had repeated code blocks for parsing different box types (MediaBox, CropBox, ArtBox, BleedBox, TrimBox).
|
|
107
100
|
|
|
108
|
-
###
|
|
109
|
-
|
|
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
|
|
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
|
-
-
|
|
168
|
-
- Or:
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|