acro_that 0.1.7 → 1.0.0
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/CHANGELOG.md +5 -0
- data/Gemfile.lock +1 -1
- data/README.md +86 -3
- data/docs/clear_fields.md +10 -10
- data/lib/acro_that/actions/add_field.rb +30 -447
- data/lib/acro_that/actions/update_field.rb +89 -14
- data/lib/acro_that/dict_scan.rb +26 -0
- data/lib/acro_that/document.rb +63 -17
- data/lib/acro_that/fields/base.rb +365 -0
- data/lib/acro_that/fields/checkbox.rb +131 -0
- data/lib/acro_that/fields/radio.rb +198 -0
- data/lib/acro_that/{actions/add_signature_appearance.rb → fields/signature.rb} +64 -93
- data/lib/acro_that/fields/text.rb +31 -0
- data/lib/acro_that/version.rb +1 -1
- data/lib/acro_that.rb +10 -2
- metadata +7 -3
data/lib/acro_that/dict_scan.rb
CHANGED
|
@@ -130,6 +130,32 @@ module AcroThat
|
|
|
130
130
|
end
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
+
# Encode a string as a PDF name, escaping special characters with hex encoding
|
|
134
|
+
# PDF names must escape: # ( ) < > [ ] { } / % and control characters
|
|
135
|
+
# Example: "(Two Hr) Priority 2" becomes "/#28Two Hr#29 Priority 2"
|
|
136
|
+
def encode_pdf_name(name)
|
|
137
|
+
name_str = name.to_s
|
|
138
|
+
# Remove leading / if present (we'll add it back)
|
|
139
|
+
name_str = name_str[1..] if name_str.start_with?("/")
|
|
140
|
+
|
|
141
|
+
# Encode special characters as hex
|
|
142
|
+
encoded = name_str.each_byte.map do |byte|
|
|
143
|
+
char = byte.chr
|
|
144
|
+
# PDF name special characters that need hex encoding: # ( ) < > [ ] { } / %
|
|
145
|
+
# Also encode control characters (0x00-0x1F, 0x7F) and non-ASCII (0x80-0xFF)
|
|
146
|
+
if ["#", "(", ")", "<", ">", "[", "]", "{", "}", "/", "%"].include?(char) ||
|
|
147
|
+
byte.between?(0x00, 0x1F) || byte == 0x7F || byte.between?(0x80, 0xFF)
|
|
148
|
+
# Hex encode: # followed by 2-digit hex
|
|
149
|
+
"##{byte.to_s(16).upcase.rjust(2, '0')}"
|
|
150
|
+
else
|
|
151
|
+
# Regular printable ASCII: use as-is
|
|
152
|
+
char
|
|
153
|
+
end
|
|
154
|
+
end.join
|
|
155
|
+
|
|
156
|
+
"/#{encoded}"
|
|
157
|
+
end
|
|
158
|
+
|
|
133
159
|
# Format a metadata key as a PDF dictionary key (ensure it starts with /)
|
|
134
160
|
def format_pdf_key(key)
|
|
135
161
|
key_str = key.to_s
|
data/lib/acro_that/document.rb
CHANGED
|
@@ -18,13 +18,18 @@ module AcroThat
|
|
|
18
18
|
|
|
19
19
|
def initialize(path_or_io)
|
|
20
20
|
@path = path_or_io.is_a?(String) ? path_or_io : nil
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
raw_bytes = case path_or_io
|
|
22
|
+
when String then File.binread(path_or_io)
|
|
23
|
+
else path_or_io.binmode
|
|
24
|
+
path_or_io.read
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Extract PDF content if wrapped in multipart form data
|
|
28
|
+
@raw = extract_pdf_from_form_data(raw_bytes).freeze
|
|
26
29
|
@resolver = AcroThat::ObjectResolver.new(@raw)
|
|
27
30
|
@patches = []
|
|
31
|
+
# Track radio button groups: group_id -> parent_field_ref
|
|
32
|
+
@radio_groups = {}
|
|
28
33
|
end
|
|
29
34
|
|
|
30
35
|
# Flatten this document to remove incremental updates
|
|
@@ -32,18 +37,27 @@ module AcroThat
|
|
|
32
37
|
root_ref = @resolver.root_ref
|
|
33
38
|
raise "Cannot flatten: no /Root found" unless root_ref
|
|
34
39
|
|
|
35
|
-
|
|
40
|
+
# First pass: collect only references (lightweight) and find max_obj_num
|
|
41
|
+
# This avoids loading all object bodies into memory at once
|
|
42
|
+
refs = []
|
|
43
|
+
max_obj_num = 0
|
|
36
44
|
@resolver.each_object do |ref, body|
|
|
37
|
-
|
|
45
|
+
if body
|
|
46
|
+
refs << ref
|
|
47
|
+
max_obj_num = [max_obj_num, ref[0]].max
|
|
48
|
+
end
|
|
38
49
|
end
|
|
39
50
|
|
|
40
|
-
|
|
51
|
+
# Sort references by object number
|
|
52
|
+
refs.sort_by! { |ref| ref[0] }
|
|
41
53
|
|
|
54
|
+
# Second pass: write objects in sorted order, retrieving bodies on demand
|
|
42
55
|
writer = PDFWriter.new
|
|
43
56
|
writer.write_header
|
|
44
57
|
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
refs.each do |ref|
|
|
59
|
+
body = @resolver.object_body(ref)
|
|
60
|
+
writer.write_object(ref, body) if body
|
|
47
61
|
end
|
|
48
62
|
|
|
49
63
|
writer.write_xref
|
|
@@ -55,7 +69,6 @@ module AcroThat
|
|
|
55
69
|
end
|
|
56
70
|
|
|
57
71
|
# Write trailer
|
|
58
|
-
max_obj_num = objects.map { |obj| obj[:ref][0] }.max || 0
|
|
59
72
|
writer.write_trailer(max_obj_num + 1, root_ref, info_ref)
|
|
60
73
|
|
|
61
74
|
writer.output
|
|
@@ -378,9 +391,11 @@ module AcroThat
|
|
|
378
391
|
all_fields = list_fields
|
|
379
392
|
|
|
380
393
|
if block_given?
|
|
381
|
-
# Use block to determine which fields to
|
|
394
|
+
# Use block to determine which fields to remove
|
|
395
|
+
# Block receives field object (can check field.name, field.value, etc.)
|
|
396
|
+
# Return true to remove the field, false to keep it
|
|
382
397
|
all_fields.each do |field|
|
|
383
|
-
fields_to_remove.add(field.name)
|
|
398
|
+
fields_to_remove.add(field.name) if yield(field)
|
|
384
399
|
end
|
|
385
400
|
elsif keep_fields
|
|
386
401
|
# Keep only specified fields
|
|
@@ -440,19 +455,28 @@ module AcroThat
|
|
|
440
455
|
end
|
|
441
456
|
end
|
|
442
457
|
|
|
443
|
-
# Collect
|
|
444
|
-
|
|
458
|
+
# Collect refs to write (excluding removed fields and widgets)
|
|
459
|
+
# Store refs only initially to avoid loading all bodies into memory at once
|
|
460
|
+
refs_to_keep = []
|
|
445
461
|
@resolver.each_object do |ref, body|
|
|
446
462
|
next if field_refs_to_remove.include?(ref)
|
|
447
463
|
next if widget_refs_to_remove.include?(ref)
|
|
448
464
|
next unless body
|
|
449
465
|
|
|
450
|
-
|
|
466
|
+
refs_to_keep << ref
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Build objects hash - load bodies only for objects we need to modify
|
|
470
|
+
# For unmodified objects, we'll load bodies on demand during writing
|
|
471
|
+
objects = []
|
|
472
|
+
refs_to_keep.each do |ref|
|
|
473
|
+
body = @resolver.object_body(ref)
|
|
474
|
+
objects << { ref: ref, body: body } if body
|
|
451
475
|
end
|
|
452
476
|
|
|
453
477
|
# Process AcroForm to remove field references from /Fields array
|
|
454
478
|
af_ref = acroform_ref
|
|
455
|
-
if af_ref
|
|
479
|
+
if af_ref && refs_to_keep.include?(af_ref)
|
|
456
480
|
# Find the AcroForm object in our objects list
|
|
457
481
|
af_obj = objects.find { |o| o[:ref] == af_ref }
|
|
458
482
|
if af_obj
|
|
@@ -634,6 +658,28 @@ module AcroThat
|
|
|
634
658
|
|
|
635
659
|
private
|
|
636
660
|
|
|
661
|
+
# Extract PDF content from multipart form data if present
|
|
662
|
+
# Some PDFs are uploaded as multipart form data with boundary markers
|
|
663
|
+
def extract_pdf_from_form_data(bytes)
|
|
664
|
+
# Check if this looks like multipart form data
|
|
665
|
+
if bytes =~ /\A------\w+/
|
|
666
|
+
# Find the PDF header
|
|
667
|
+
pdf_start = bytes.index("%PDF")
|
|
668
|
+
return bytes unless pdf_start
|
|
669
|
+
|
|
670
|
+
# Extract PDF content from start to EOF
|
|
671
|
+
pdf_end = bytes.rindex("%%EOF")
|
|
672
|
+
return bytes unless pdf_end
|
|
673
|
+
|
|
674
|
+
# Extract just the PDF portion
|
|
675
|
+
pdf_content = bytes[pdf_start..(pdf_end + 4)]
|
|
676
|
+
return pdf_content
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Not form data, return as-is
|
|
680
|
+
bytes
|
|
681
|
+
end
|
|
682
|
+
|
|
637
683
|
def collect_pages_from_tree(pages_ref, page_objects)
|
|
638
684
|
pages_body = @resolver.object_body(pages_ref)
|
|
639
685
|
return unless pages_body
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AcroThat
|
|
4
|
+
module Fields
|
|
5
|
+
# Base class for field types with shared functionality
|
|
6
|
+
module Base
|
|
7
|
+
include Actions::Base
|
|
8
|
+
|
|
9
|
+
attr_reader :document, :name, :options, :metadata, :field_type, :field_value
|
|
10
|
+
|
|
11
|
+
def initialize(document, name, options = {})
|
|
12
|
+
@document = document
|
|
13
|
+
@name = name
|
|
14
|
+
@options = normalize_hash_keys(options)
|
|
15
|
+
@metadata = normalize_hash_keys(@options[:metadata] || {})
|
|
16
|
+
@field_type = determine_field_type
|
|
17
|
+
@field_value = @options[:value] || ""
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def x
|
|
21
|
+
@options[:x] || 100
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def y
|
|
25
|
+
@options[:y] || 500
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def width
|
|
29
|
+
@options[:width] || 100
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def height
|
|
33
|
+
@options[:height] || 20
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def page_num
|
|
37
|
+
@options[:page] || 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def normalize_hash_keys(hash)
|
|
43
|
+
return hash unless hash.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
hash.each_with_object({}) do |(key, value), normalized|
|
|
46
|
+
sym_key = key.is_a?(Symbol) ? key : key.to_sym
|
|
47
|
+
normalized[sym_key] = value.is_a?(Hash) ? normalize_hash_keys(value) : value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def determine_field_type
|
|
52
|
+
type_input = @options[:type] || "/Tx"
|
|
53
|
+
case type_input
|
|
54
|
+
when :text, "text", "/Tx", "/tx"
|
|
55
|
+
"/Tx"
|
|
56
|
+
when :button, "button", "/Btn", "/btn"
|
|
57
|
+
"/Btn"
|
|
58
|
+
when :radio, "radio"
|
|
59
|
+
"/Btn"
|
|
60
|
+
when :checkbox, "checkbox"
|
|
61
|
+
"/Btn"
|
|
62
|
+
when :choice, "choice", "/Ch", "/ch"
|
|
63
|
+
"/Ch"
|
|
64
|
+
when :signature, "signature", "/Sig", "/sig"
|
|
65
|
+
"/Sig"
|
|
66
|
+
else
|
|
67
|
+
type_input.to_s
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def create_field_dictionary(value, type)
|
|
72
|
+
dict = "<<\n"
|
|
73
|
+
dict += " /FT #{type}\n"
|
|
74
|
+
dict += " /T #{DictScan.encode_pdf_string(@name)}\n"
|
|
75
|
+
|
|
76
|
+
# Apply /Ff from metadata, or use default 0
|
|
77
|
+
field_flags = @metadata[:Ff] || 0
|
|
78
|
+
dict += " /Ff #{field_flags}\n"
|
|
79
|
+
|
|
80
|
+
dict += " /DA (/Helv 0 Tf 0 g)\n"
|
|
81
|
+
|
|
82
|
+
# Check if this is a radio button (has Radio flag set)
|
|
83
|
+
is_radio_field = field_flags.anybits?(32_768)
|
|
84
|
+
|
|
85
|
+
# For signature fields with image data, don't set /V (appearance stream will be added separately)
|
|
86
|
+
# For radio buttons, /V should be the export value name (e.g., "/email", "/phone")
|
|
87
|
+
# For checkboxes, set /V to normalized value (Yes/Off)
|
|
88
|
+
# For other fields, set /V normally
|
|
89
|
+
should_set_value = if type == "/Sig" && value && !value.empty?
|
|
90
|
+
!(value.is_a?(String) && (value.start_with?("data:image/") || (value.length > 50 && value.match?(%r{^[A-Za-z0-9+/]*={0,2}$}))))
|
|
91
|
+
else
|
|
92
|
+
true
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# For radio buttons: use export value as PDF name (e.g., "/email")
|
|
96
|
+
# For checkboxes: normalize to "Yes" or "Off"
|
|
97
|
+
# For other fields: use value as-is
|
|
98
|
+
normalized_field_value = if is_radio_field && value && !value.to_s.empty?
|
|
99
|
+
# Encode export value as PDF name (escapes special characters like parentheses)
|
|
100
|
+
DictScan.encode_pdf_name(value)
|
|
101
|
+
elsif type == "/Btn" && value
|
|
102
|
+
value_str = value.to_s
|
|
103
|
+
is_checked = ["Yes", "/Yes", "true"].include?(value_str) || value == true
|
|
104
|
+
is_checked ? "Yes" : "Off"
|
|
105
|
+
else
|
|
106
|
+
value
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# For radio buttons, /V should only be set if explicitly selected
|
|
110
|
+
# For other fields, encode as PDF string
|
|
111
|
+
if should_set_value && normalized_field_value && !normalized_field_value.to_s.empty?
|
|
112
|
+
# For radio buttons, only set /V if selected option is explicitly set to true
|
|
113
|
+
if is_radio_field
|
|
114
|
+
# Only set /V for radio buttons if selected option is true
|
|
115
|
+
if [true, "true"].include?(@options[:selected]) && normalized_field_value.to_s.start_with?("/")
|
|
116
|
+
dict += " /V #{normalized_field_value}\n"
|
|
117
|
+
end
|
|
118
|
+
else
|
|
119
|
+
dict += " /V #{DictScan.encode_pdf_string(normalized_field_value)}\n"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Apply other metadata entries (excluding Ff which we handled above)
|
|
124
|
+
@metadata.each do |key, val|
|
|
125
|
+
next if key == :Ff
|
|
126
|
+
|
|
127
|
+
pdf_key = DictScan.format_pdf_key(key)
|
|
128
|
+
pdf_value = DictScan.format_pdf_value(val)
|
|
129
|
+
dict += " #{pdf_key} #{pdf_value}\n"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
dict += ">>"
|
|
133
|
+
dict
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def create_widget_annotation_with_parent(_widget_obj_num, parent_ref, page_ref, x, y, width, height, type, value,
|
|
137
|
+
is_radio: false)
|
|
138
|
+
rect_array = "[#{x} #{y} #{x + width} #{y + height}]"
|
|
139
|
+
widget = "<<\n"
|
|
140
|
+
widget += " /Type /Annot\n"
|
|
141
|
+
widget += " /Subtype /Widget\n"
|
|
142
|
+
widget += " /Parent #{parent_ref[0]} #{parent_ref[1]} R\n"
|
|
143
|
+
widget += " /P #{page_ref[0]} #{page_ref[1]} R\n" if page_ref
|
|
144
|
+
|
|
145
|
+
widget += " /FT #{type}\n"
|
|
146
|
+
if is_radio
|
|
147
|
+
widget += " /T #{DictScan.encode_pdf_string(@name)}\n"
|
|
148
|
+
else
|
|
149
|
+
|
|
150
|
+
should_set_value = if type == "/Sig" && value && !value.empty?
|
|
151
|
+
!(value.is_a?(String) && (value.start_with?("data:image/") || (value.length > 50 && value.match?(%r{^[A-Za-z0-9+/]*={0,2}$}))))
|
|
152
|
+
elsif type == "/Btn"
|
|
153
|
+
true
|
|
154
|
+
else
|
|
155
|
+
true
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
if type == "/Btn" && should_set_value
|
|
159
|
+
value_str = value.to_s
|
|
160
|
+
is_checked = ["Yes", "/Yes", "true"].include?(value_str) || value == true
|
|
161
|
+
checkbox_value = is_checked ? "Yes" : "Off"
|
|
162
|
+
widget += " /V #{DictScan.encode_pdf_string(checkbox_value)}\n"
|
|
163
|
+
elsif should_set_value && value && !value.empty?
|
|
164
|
+
widget += " /V #{DictScan.encode_pdf_string(value)}\n"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
widget += " /Rect #{rect_array}\n"
|
|
169
|
+
widget += " /F 4\n"
|
|
170
|
+
|
|
171
|
+
widget += if is_radio
|
|
172
|
+
" /MK << /BC [0.0] /BG [1.0] >>\n"
|
|
173
|
+
else
|
|
174
|
+
" /DA (/Helv 0 Tf 0 g)\n"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
@metadata.each do |key, val|
|
|
178
|
+
pdf_key = DictScan.format_pdf_key(key)
|
|
179
|
+
pdf_value = DictScan.format_pdf_value(val)
|
|
180
|
+
next if ["/F", "/V"].include?(pdf_key)
|
|
181
|
+
next if is_radio && ["/Ff", "/DA"].include?(pdf_key)
|
|
182
|
+
|
|
183
|
+
widget += " #{pdf_key} #{pdf_value}\n"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
widget += ">>"
|
|
187
|
+
widget
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def add_field_to_acroform_with_defaults(field_obj_num)
|
|
191
|
+
af_ref = acroform_ref
|
|
192
|
+
return false unless af_ref
|
|
193
|
+
|
|
194
|
+
af_body = get_object_body_with_patch(af_ref)
|
|
195
|
+
# Use +"" instead of dup to create a mutable copy without keeping reference to original
|
|
196
|
+
patched = af_body + ""
|
|
197
|
+
|
|
198
|
+
# Step 1: Add field to /Fields array
|
|
199
|
+
fields_array_ref = DictScan.value_token_after("/Fields", patched)
|
|
200
|
+
|
|
201
|
+
if fields_array_ref && fields_array_ref =~ /\A(\d+)\s+(\d+)\s+R/
|
|
202
|
+
arr_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
|
|
203
|
+
arr_body = get_object_body_with_patch(arr_ref)
|
|
204
|
+
new_body = DictScan.add_ref_to_array(arr_body, [field_obj_num, 0])
|
|
205
|
+
apply_patch(arr_ref, new_body, arr_body)
|
|
206
|
+
elsif patched.include?("/Fields")
|
|
207
|
+
patched = DictScan.add_ref_to_inline_array(patched, "/Fields", [field_obj_num, 0])
|
|
208
|
+
else
|
|
209
|
+
patched = DictScan.upsert_key_value(patched, "/Fields", "[#{field_obj_num} 0 R]")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Step 2: Ensure /NeedAppearances false (we provide custom appearance streams)
|
|
213
|
+
# Setting to false tells viewers to use our custom appearances instead of generating defaults
|
|
214
|
+
# If we don't set this or set it to true, viewers will ignore our custom appearances and
|
|
215
|
+
# generate their own default appearances (e.g., circular radio buttons instead of our squares)
|
|
216
|
+
patched = if patched.include?("/NeedAppearances")
|
|
217
|
+
# Replace existing /NeedAppearances with false
|
|
218
|
+
DictScan.replace_key_value(patched, "/NeedAppearances", "false")
|
|
219
|
+
else
|
|
220
|
+
DictScan.upsert_key_value(patched, "/NeedAppearances", "false")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Step 2.5: Remove /XFA if present
|
|
224
|
+
if patched.include?("/XFA")
|
|
225
|
+
xfa_pattern = %r{/XFA(?=[\s(<\[/])}
|
|
226
|
+
if patched.match(xfa_pattern)
|
|
227
|
+
xfa_value = DictScan.value_token_after("/XFA", patched)
|
|
228
|
+
if xfa_value
|
|
229
|
+
xfa_match = patched.match(xfa_pattern)
|
|
230
|
+
if xfa_match
|
|
231
|
+
key_start = xfa_match.begin(0)
|
|
232
|
+
value_start = xfa_match.end(0)
|
|
233
|
+
value_start += 1 while value_start < patched.length && patched[value_start] =~ /\s/
|
|
234
|
+
value_end = value_start + xfa_value.length
|
|
235
|
+
value_end += 1 while value_end < patched.length && patched[value_end] =~ /\s/
|
|
236
|
+
before = patched[0...key_start]
|
|
237
|
+
before = before.rstrip
|
|
238
|
+
after = patched[value_end..]
|
|
239
|
+
patched = "#{before} #{after.lstrip}".strip
|
|
240
|
+
patched = patched.gsub(/\s+/, " ")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Step 3: Ensure /DR /Font has /Helv mapping
|
|
247
|
+
unless patched.include?("/DR") && patched.include?("/Helv")
|
|
248
|
+
font_obj_num = next_fresh_object_number
|
|
249
|
+
font_body = "<<\n /Type /Font\n /Subtype /Type1\n /BaseFont /Helvetica\n>>"
|
|
250
|
+
document.instance_variable_get(:@patches) << { ref: [font_obj_num, 0], body: font_body }
|
|
251
|
+
|
|
252
|
+
if patched.include?("/DR")
|
|
253
|
+
dr_tok = DictScan.value_token_after("/DR", patched)
|
|
254
|
+
if dr_tok && dr_tok.start_with?("<<")
|
|
255
|
+
unless dr_tok.include?("/Font")
|
|
256
|
+
new_dr_tok = dr_tok.chomp(">>") + " /Font << /Helv #{font_obj_num} 0 R >>\n>>"
|
|
257
|
+
patched = patched.sub(dr_tok) { |_| new_dr_tok }
|
|
258
|
+
end
|
|
259
|
+
else
|
|
260
|
+
patched = DictScan.replace_key_value(patched, "/DR", "<< /Font << /Helv #{font_obj_num} 0 R >> >>")
|
|
261
|
+
end
|
|
262
|
+
else
|
|
263
|
+
patched = DictScan.upsert_key_value(patched, "/DR", "<< /Font << /Helv #{font_obj_num} 0 R >> >>")
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
apply_patch(af_ref, patched, af_body)
|
|
268
|
+
true
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def find_page_ref(page_num)
|
|
272
|
+
find_page_by_number(page_num)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def add_widget_to_page(widget_obj_num, page_num)
|
|
276
|
+
target_page_ref = find_page_ref(page_num)
|
|
277
|
+
return false unless target_page_ref
|
|
278
|
+
|
|
279
|
+
page_body = get_object_body_with_patch(target_page_ref)
|
|
280
|
+
|
|
281
|
+
new_body = if page_body =~ %r{/Annots\s*\[(.*?)\]}m
|
|
282
|
+
result = DictScan.add_ref_to_inline_array(page_body, "/Annots", [widget_obj_num, 0])
|
|
283
|
+
if result && result != page_body
|
|
284
|
+
result
|
|
285
|
+
else
|
|
286
|
+
annots_array = ::Regexp.last_match(1)
|
|
287
|
+
ref_token = "#{widget_obj_num} 0 R"
|
|
288
|
+
new_annots = if annots_array.strip.empty?
|
|
289
|
+
"[#{ref_token}]"
|
|
290
|
+
else
|
|
291
|
+
"[#{annots_array} #{ref_token}]"
|
|
292
|
+
end
|
|
293
|
+
page_body.sub(%r{/Annots\s*\[.*?\]}, "/Annots #{new_annots}")
|
|
294
|
+
end
|
|
295
|
+
elsif page_body =~ %r{/Annots\s+(\d+)\s+(\d+)\s+R}
|
|
296
|
+
annots_array_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
|
|
297
|
+
annots_array_body = get_object_body_with_patch(annots_array_ref)
|
|
298
|
+
|
|
299
|
+
ref_token = "#{widget_obj_num} 0 R"
|
|
300
|
+
if annots_array_body
|
|
301
|
+
new_annots_body = if annots_array_body.strip == "[]"
|
|
302
|
+
"[#{ref_token}]"
|
|
303
|
+
elsif annots_array_body.strip.start_with?("[") && annots_array_body.strip.end_with?("]")
|
|
304
|
+
without_brackets = annots_array_body.strip[1..-2].strip
|
|
305
|
+
"[#{without_brackets} #{ref_token}]"
|
|
306
|
+
else
|
|
307
|
+
"[#{annots_array_body} #{ref_token}]"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
apply_patch(annots_array_ref, new_annots_body, annots_array_body)
|
|
311
|
+
page_body
|
|
312
|
+
else
|
|
313
|
+
page_body.sub(%r{/Annots\s+\d+\s+\d+\s+R}, "/Annots [#{ref_token}]")
|
|
314
|
+
end
|
|
315
|
+
else
|
|
316
|
+
ref_token = "#{widget_obj_num} 0 R"
|
|
317
|
+
if page_body.include?(">>")
|
|
318
|
+
page_body.reverse.sub(">>".reverse, "/Annots [#{ref_token}]>>".reverse).reverse
|
|
319
|
+
else
|
|
320
|
+
page_body + " /Annots [#{ref_token}]"
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
apply_patch(target_page_ref, new_body, page_body) if new_body && new_body != page_body
|
|
325
|
+
true
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def add_widget_to_parent_kids(parent_ref, widget_obj_num)
|
|
329
|
+
parent_body = get_object_body_with_patch(parent_ref)
|
|
330
|
+
return unless parent_body
|
|
331
|
+
|
|
332
|
+
kids_array_ref = DictScan.value_token_after("/Kids", parent_body)
|
|
333
|
+
|
|
334
|
+
if kids_array_ref && kids_array_ref =~ /\A(\d+)\s+(\d+)\s+R\z/
|
|
335
|
+
arr_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
|
|
336
|
+
arr_body = get_object_body_with_patch(arr_ref)
|
|
337
|
+
new_body = DictScan.add_ref_to_array(arr_body, [widget_obj_num, 0])
|
|
338
|
+
apply_patch(arr_ref, new_body, arr_body)
|
|
339
|
+
elsif kids_array_ref && kids_array_ref.start_with?("[")
|
|
340
|
+
new_body = DictScan.add_ref_to_inline_array(parent_body, "/Kids", [widget_obj_num, 0])
|
|
341
|
+
apply_patch(parent_ref, new_body, parent_body) if new_body && new_body != parent_body
|
|
342
|
+
else
|
|
343
|
+
new_body = DictScan.upsert_key_value(parent_body, "/Kids", "[#{widget_obj_num} 0 R]")
|
|
344
|
+
apply_patch(parent_ref, new_body, parent_body) if new_body && new_body != parent_body
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def build_form_xobject(content_stream, width, height)
|
|
349
|
+
dict = "<<\n"
|
|
350
|
+
dict += " /Type /XObject\n"
|
|
351
|
+
dict += " /Subtype /Form\n"
|
|
352
|
+
dict += " /BBox [0 0 #{width} #{height}]\n"
|
|
353
|
+
dict += " /Matrix [1 0 0 1 0 0]\n"
|
|
354
|
+
dict += " /Resources << >>\n"
|
|
355
|
+
dict += " /Length #{content_stream.bytesize}\n"
|
|
356
|
+
dict += ">>\n"
|
|
357
|
+
dict += "stream\n"
|
|
358
|
+
dict += content_stream
|
|
359
|
+
dict += "\nendstream"
|
|
360
|
+
|
|
361
|
+
dict
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AcroThat
|
|
4
|
+
module Fields
|
|
5
|
+
# Handles checkbox field creation
|
|
6
|
+
class Checkbox
|
|
7
|
+
include Base
|
|
8
|
+
|
|
9
|
+
attr_reader :field_obj_num
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
@field_obj_num = next_fresh_object_number
|
|
13
|
+
widget_obj_num = @field_obj_num + 1
|
|
14
|
+
|
|
15
|
+
field_body = create_field_dictionary(@field_value, @field_type)
|
|
16
|
+
page_ref = find_page_ref(page_num)
|
|
17
|
+
|
|
18
|
+
widget_body = create_widget_annotation_with_parent(widget_obj_num, [@field_obj_num, 0], page_ref, x, y, width,
|
|
19
|
+
height, @field_type, @field_value)
|
|
20
|
+
|
|
21
|
+
document.instance_variable_get(:@patches) << { ref: [@field_obj_num, 0], body: field_body }
|
|
22
|
+
document.instance_variable_get(:@patches) << { ref: [widget_obj_num, 0], body: widget_body }
|
|
23
|
+
|
|
24
|
+
add_field_to_acroform_with_defaults(@field_obj_num)
|
|
25
|
+
add_widget_to_page(widget_obj_num, page_num)
|
|
26
|
+
|
|
27
|
+
add_checkbox_appearance(widget_obj_num)
|
|
28
|
+
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def add_checkbox_appearance(widget_obj_num)
|
|
35
|
+
yes_obj_num = next_fresh_object_number
|
|
36
|
+
off_obj_num = yes_obj_num + 1
|
|
37
|
+
|
|
38
|
+
yes_body = create_checkbox_yes_appearance(width, height)
|
|
39
|
+
document.instance_variable_get(:@patches) << { ref: [yes_obj_num, 0], body: yes_body }
|
|
40
|
+
|
|
41
|
+
off_body = create_checkbox_off_appearance(width, height)
|
|
42
|
+
document.instance_variable_get(:@patches) << { ref: [off_obj_num, 0], body: off_body }
|
|
43
|
+
|
|
44
|
+
widget_ref = [widget_obj_num, 0]
|
|
45
|
+
original_widget_body = get_object_body_with_patch(widget_ref)
|
|
46
|
+
# Use +"" instead of dup to create a mutable copy without keeping reference to original
|
|
47
|
+
widget_body = original_widget_body + ""
|
|
48
|
+
|
|
49
|
+
ap_dict = "<<\n /N <<\n /Yes #{yes_obj_num} 0 R\n /Off #{off_obj_num} 0 R\n >>\n>>"
|
|
50
|
+
|
|
51
|
+
widget_body = if widget_body.include?("/AP")
|
|
52
|
+
DictScan.replace_key_value(widget_body, "/AP", ap_dict)
|
|
53
|
+
else
|
|
54
|
+
DictScan.upsert_key_value(widget_body, "/AP", ap_dict)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
value_str = @field_value.to_s
|
|
58
|
+
is_checked = value_str == "Yes" || value_str == "/Yes" || value_str == "true" || @field_value == true
|
|
59
|
+
normalized_value = is_checked ? "Yes" : "Off"
|
|
60
|
+
|
|
61
|
+
as_value = if normalized_value == "Yes"
|
|
62
|
+
"/Yes"
|
|
63
|
+
else
|
|
64
|
+
"/Off"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
widget_body = if widget_body.include?("/AS")
|
|
68
|
+
DictScan.replace_key_value(widget_body, "/AS", as_value)
|
|
69
|
+
else
|
|
70
|
+
DictScan.upsert_key_value(widget_body, "/AS", as_value)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
apply_patch(widget_ref, widget_body, original_widget_body)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def create_checkbox_yes_appearance(width, height)
|
|
77
|
+
border_width = [width * 0.08, height * 0.08].min
|
|
78
|
+
line_width = [width * 0.05, height * 0.05].min
|
|
79
|
+
|
|
80
|
+
check_x1 = width * 0.25
|
|
81
|
+
check_y1 = height * 0.45
|
|
82
|
+
check_x2 = width * 0.45
|
|
83
|
+
check_y2 = height * 0.25
|
|
84
|
+
check_x3 = width * 0.75
|
|
85
|
+
check_y3 = height * 0.75
|
|
86
|
+
|
|
87
|
+
content_stream = "q\n"
|
|
88
|
+
# Draw square border around field bounds
|
|
89
|
+
content_stream += "0 0 0 RG\n" # Black stroke color
|
|
90
|
+
content_stream += "#{line_width} w\n" # Line width
|
|
91
|
+
# Draw rectangle from (0,0) to (width, height)
|
|
92
|
+
content_stream += "0 0 m\n"
|
|
93
|
+
content_stream += "#{width} 0 l\n"
|
|
94
|
+
content_stream += "#{width} #{height} l\n"
|
|
95
|
+
content_stream += "0 #{height} l\n"
|
|
96
|
+
content_stream += "0 0 l\n"
|
|
97
|
+
content_stream += "S\n" # Stroke the border
|
|
98
|
+
|
|
99
|
+
# Draw checkmark
|
|
100
|
+
content_stream += "0 0 0 rg\n" # Black fill color
|
|
101
|
+
content_stream += "#{border_width} w\n" # Line width for checkmark
|
|
102
|
+
content_stream += "#{check_x1} #{check_y1} m\n"
|
|
103
|
+
content_stream += "#{check_x2} #{check_y2} l\n"
|
|
104
|
+
content_stream += "#{check_x3} #{check_y3} l\n"
|
|
105
|
+
content_stream += "S\n" # Stroke the checkmark
|
|
106
|
+
content_stream += "Q\n"
|
|
107
|
+
|
|
108
|
+
build_form_xobject(content_stream, width, height)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def create_checkbox_off_appearance(width, height)
|
|
112
|
+
line_width = [width * 0.05, height * 0.05].min
|
|
113
|
+
|
|
114
|
+
content_stream = "q\n"
|
|
115
|
+
# Draw square border around field bounds
|
|
116
|
+
content_stream += "0 0 0 RG\n" # Black stroke color
|
|
117
|
+
content_stream += "#{line_width} w\n" # Line width
|
|
118
|
+
# Draw rectangle from (0,0) to (width, height)
|
|
119
|
+
content_stream += "0 0 m\n"
|
|
120
|
+
content_stream += "#{width} 0 l\n"
|
|
121
|
+
content_stream += "#{width} #{height} l\n"
|
|
122
|
+
content_stream += "0 #{height} l\n"
|
|
123
|
+
content_stream += "0 0 l\n"
|
|
124
|
+
content_stream += "S\n" # Stroke the border
|
|
125
|
+
content_stream += "Q\n"
|
|
126
|
+
|
|
127
|
+
build_form_xobject(content_stream, width, height)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|