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.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rubocop.yml +78 -0
- data/CHANGELOG.md +122 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +90 -0
- data/README.md +518 -0
- data/Rakefile +18 -0
- data/corp_pdf.gemspec +35 -0
- data/docs/README.md +111 -0
- data/docs/clear_fields.md +202 -0
- data/docs/dict_scan_explained.md +341 -0
- data/docs/object_streams.md +311 -0
- data/docs/pdf_structure.md +251 -0
- data/issues/README.md +59 -0
- 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 +259 -0
- data/lib/corp_pdf/actions/add_field.rb +73 -0
- data/lib/corp_pdf/actions/base.rb +48 -0
- data/lib/corp_pdf/actions/remove_field.rb +154 -0
- data/lib/corp_pdf/actions/update_field.rb +663 -0
- data/lib/corp_pdf/dict_scan.rb +523 -0
- data/lib/corp_pdf/document.rb +782 -0
- data/lib/corp_pdf/field.rb +145 -0
- data/lib/corp_pdf/fields/base.rb +384 -0
- data/lib/corp_pdf/fields/checkbox.rb +164 -0
- data/lib/corp_pdf/fields/radio.rb +220 -0
- data/lib/corp_pdf/fields/signature.rb +393 -0
- data/lib/corp_pdf/fields/text.rb +31 -0
- data/lib/corp_pdf/incremental_writer.rb +245 -0
- data/lib/corp_pdf/object_resolver.rb +381 -0
- data/lib/corp_pdf/objstm.rb +75 -0
- data/lib/corp_pdf/page.rb +90 -0
- data/lib/corp_pdf/pdf_writer.rb +133 -0
- data/lib/corp_pdf/version.rb +5 -0
- data/lib/corp_pdf.rb +35 -0
- data/publish +183 -0
- metadata +169 -0
|
@@ -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
|