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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AcroThat
|
|
4
|
+
module Fields
|
|
5
|
+
# Handles radio button field creation
|
|
6
|
+
class Radio
|
|
7
|
+
include Base
|
|
8
|
+
|
|
9
|
+
attr_reader :field_obj_num
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
@field_value = @field_value.to_s.gsub(" ", "")
|
|
13
|
+
group_id = @options[:group_id]
|
|
14
|
+
radio_groups = @document.instance_variable_get(:@radio_groups)
|
|
15
|
+
parent_ref = radio_groups[group_id]
|
|
16
|
+
|
|
17
|
+
if parent_ref
|
|
18
|
+
# Subsequent radio button: add widget to existing parent's Kids array
|
|
19
|
+
add_subsequent_widget(parent_ref)
|
|
20
|
+
else
|
|
21
|
+
# First radio button in group: create parent field and first widget
|
|
22
|
+
create_first_widget(group_id, radio_groups)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def add_subsequent_widget(parent_ref)
|
|
31
|
+
widget_obj_num = next_fresh_object_number
|
|
32
|
+
page_ref = find_page_ref(page_num)
|
|
33
|
+
|
|
34
|
+
widget_body = create_widget_annotation_with_parent(widget_obj_num, parent_ref, page_ref, x, y, width,
|
|
35
|
+
height, @field_type, @field_value, is_radio: true)
|
|
36
|
+
|
|
37
|
+
document.instance_variable_get(:@patches) << { ref: [widget_obj_num, 0], body: widget_body }
|
|
38
|
+
|
|
39
|
+
add_widget_to_parent_kids(parent_ref, widget_obj_num)
|
|
40
|
+
add_field_to_acroform_with_defaults(widget_obj_num)
|
|
41
|
+
add_widget_to_page(widget_obj_num, page_num)
|
|
42
|
+
|
|
43
|
+
add_radio_button_appearance(widget_obj_num, @field_value, x, y, width, height, parent_ref)
|
|
44
|
+
|
|
45
|
+
@field_obj_num = parent_ref[0]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_first_widget(group_id, radio_groups)
|
|
49
|
+
@field_obj_num = next_fresh_object_number
|
|
50
|
+
widget_obj_num = @field_obj_num + 1
|
|
51
|
+
|
|
52
|
+
field_body = create_field_dictionary(@field_value, @field_type)
|
|
53
|
+
page_ref = find_page_ref(page_num)
|
|
54
|
+
|
|
55
|
+
widget_body = create_widget_annotation_with_parent(widget_obj_num, [@field_obj_num, 0], page_ref, x, y, width,
|
|
56
|
+
height, @field_type, @field_value, is_radio: true)
|
|
57
|
+
|
|
58
|
+
document.instance_variable_get(:@patches) << { ref: [@field_obj_num, 0], body: field_body }
|
|
59
|
+
document.instance_variable_get(:@patches) << { ref: [widget_obj_num, 0], body: widget_body }
|
|
60
|
+
|
|
61
|
+
add_widget_to_parent_kids([@field_obj_num, 0], widget_obj_num)
|
|
62
|
+
add_field_to_acroform_with_defaults(@field_obj_num)
|
|
63
|
+
add_field_to_acroform_with_defaults(widget_obj_num)
|
|
64
|
+
add_widget_to_page(widget_obj_num, page_num)
|
|
65
|
+
|
|
66
|
+
add_radio_button_appearance(widget_obj_num, @field_value, x, y, width, height, [@field_obj_num, 0])
|
|
67
|
+
|
|
68
|
+
radio_groups[group_id] = [@field_obj_num, 0]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def add_radio_button_appearance(widget_obj_num, export_value, _x, _y, width, height, parent_ref = nil)
|
|
72
|
+
widget_ref = [widget_obj_num, 0]
|
|
73
|
+
original_widget_body = get_object_body_with_patch(widget_ref)
|
|
74
|
+
return unless original_widget_body
|
|
75
|
+
|
|
76
|
+
# Store original before modifying to avoid loading again
|
|
77
|
+
widget_body = original_widget_body + ""
|
|
78
|
+
|
|
79
|
+
# Ensure we have a valid export value - if empty, generate a unique one
|
|
80
|
+
# Export value must be unique for each widget in the group for mutual exclusivity
|
|
81
|
+
if export_value.nil? || export_value.to_s.empty?
|
|
82
|
+
# Generate unique export value based on widget object number
|
|
83
|
+
export_value = "widget_#{widget_obj_num}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Encode export value as PDF name (escapes special characters like parentheses)
|
|
87
|
+
export_name = DictScan.encode_pdf_name(export_value)
|
|
88
|
+
|
|
89
|
+
unchecked_obj_num = next_fresh_object_number
|
|
90
|
+
unchecked_body = create_radio_unchecked_appearance(width, height)
|
|
91
|
+
document.instance_variable_get(:@patches) << { ref: [unchecked_obj_num, 0], body: unchecked_body }
|
|
92
|
+
|
|
93
|
+
checked_obj_num = next_fresh_object_number
|
|
94
|
+
checked_body = create_radio_checked_appearance(width, height)
|
|
95
|
+
document.instance_variable_get(:@patches) << { ref: [checked_obj_num, 0], body: checked_body }
|
|
96
|
+
|
|
97
|
+
widget_ap_dict = "<<\n /N <<\n /Off #{unchecked_obj_num} 0 R\n #{export_name} #{checked_obj_num} 0 R\n >>\n>>"
|
|
98
|
+
|
|
99
|
+
widget_body = if widget_body.include?("/AP")
|
|
100
|
+
DictScan.replace_key_value(widget_body, "/AP", widget_ap_dict)
|
|
101
|
+
else
|
|
102
|
+
DictScan.upsert_key_value(widget_body, "/AP", widget_ap_dict)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Determine if this button should be selected by default
|
|
106
|
+
# Only set selected if the selected option is explicitly set to true
|
|
107
|
+
should_be_selected = [true, "true"].include?(@options[:selected])
|
|
108
|
+
|
|
109
|
+
as_value = should_be_selected ? export_name : "/Off"
|
|
110
|
+
widget_body = if widget_body.include?("/AS")
|
|
111
|
+
DictScan.replace_key_value(widget_body, "/AS", as_value)
|
|
112
|
+
else
|
|
113
|
+
DictScan.upsert_key_value(widget_body, "/AS", as_value)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Use stored original_widget_body instead of loading again
|
|
117
|
+
apply_patch(widget_ref, widget_body, original_widget_body)
|
|
118
|
+
|
|
119
|
+
# Track original_parent_body outside blocks so we can reuse it
|
|
120
|
+
original_parent_body = nil
|
|
121
|
+
|
|
122
|
+
# Update parent field's /V if this button is selected by default
|
|
123
|
+
if parent_ref && should_be_selected
|
|
124
|
+
original_parent_body = get_object_body_with_patch(parent_ref)
|
|
125
|
+
if original_parent_body
|
|
126
|
+
# Store original before modifying
|
|
127
|
+
parent_body = original_parent_body + ""
|
|
128
|
+
# Update parent's /V to match the selected button's export value
|
|
129
|
+
parent_body = if parent_body.include?("/V")
|
|
130
|
+
DictScan.replace_key_value(parent_body, "/V", export_name)
|
|
131
|
+
else
|
|
132
|
+
DictScan.upsert_key_value(parent_body, "/V", export_name)
|
|
133
|
+
end
|
|
134
|
+
# Use stored original_parent_body instead of loading again
|
|
135
|
+
apply_patch(parent_ref, parent_body, original_parent_body)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Update parent field's /AP if parent_ref is provided
|
|
140
|
+
if parent_ref
|
|
141
|
+
# Reuse original_parent_body if we already loaded it, otherwise load it
|
|
142
|
+
original_parent_body_for_ap = original_parent_body || get_object_body_with_patch(parent_ref)
|
|
143
|
+
return unless original_parent_body_for_ap
|
|
144
|
+
|
|
145
|
+
# Use a working copy for modification
|
|
146
|
+
parent_body_for_ap = original_parent_body_for_ap + ""
|
|
147
|
+
parent_ap_tok = DictScan.value_token_after("/AP", parent_body_for_ap)
|
|
148
|
+
if parent_ap_tok && parent_ap_tok.start_with?("<<")
|
|
149
|
+
n_tok = DictScan.value_token_after("/N", parent_ap_tok)
|
|
150
|
+
if n_tok && n_tok.start_with?("<<") && !n_tok.include?(export_name.to_s)
|
|
151
|
+
new_n_tok = n_tok.chomp(">>") + " #{export_name} #{checked_obj_num} 0 R\n>>"
|
|
152
|
+
new_ap_tok = parent_ap_tok.sub(n_tok) { |_| new_n_tok }
|
|
153
|
+
new_parent_body = parent_body_for_ap.sub(parent_ap_tok) { |_| new_ap_tok }
|
|
154
|
+
apply_patch(parent_ref, new_parent_body, original_parent_body_for_ap)
|
|
155
|
+
end
|
|
156
|
+
else
|
|
157
|
+
ap_dict = "<<\n /N <<\n #{export_name} #{checked_obj_num} 0 R\n /Off #{unchecked_obj_num} 0 R\n >>\n>>"
|
|
158
|
+
new_parent_body = DictScan.upsert_key_value(parent_body_for_ap, "/AP", ap_dict)
|
|
159
|
+
apply_patch(parent_ref, new_parent_body, original_parent_body_for_ap)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def create_radio_checked_appearance(width, height)
|
|
165
|
+
# Draw only the checkmark (no border)
|
|
166
|
+
border_width = [width * 0.08, height * 0.08].min
|
|
167
|
+
|
|
168
|
+
check_x1 = width * 0.25
|
|
169
|
+
check_y1 = height * 0.45
|
|
170
|
+
check_x2 = width * 0.45
|
|
171
|
+
check_y2 = height * 0.25
|
|
172
|
+
check_x3 = width * 0.75
|
|
173
|
+
check_y3 = height * 0.75
|
|
174
|
+
|
|
175
|
+
content_stream = "q\n"
|
|
176
|
+
# Draw checkmark only (no border)
|
|
177
|
+
content_stream += "0 0 0 rg\n" # Black fill color
|
|
178
|
+
content_stream += "#{border_width} w\n" # Line width for checkmark
|
|
179
|
+
content_stream += "#{check_x1} #{check_y1} m\n"
|
|
180
|
+
content_stream += "#{check_x2} #{check_y2} l\n"
|
|
181
|
+
content_stream += "#{check_x3} #{check_y3} l\n"
|
|
182
|
+
content_stream += "S\n" # Stroke the checkmark
|
|
183
|
+
content_stream += "Q\n"
|
|
184
|
+
|
|
185
|
+
build_form_xobject(content_stream, width, height)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def create_radio_unchecked_appearance(width, height)
|
|
189
|
+
# Empty appearance (no border, no checkmark)
|
|
190
|
+
content_stream = "q\n"
|
|
191
|
+
# Empty appearance for unchecked state
|
|
192
|
+
content_stream += "Q\n"
|
|
193
|
+
|
|
194
|
+
build_form_xobject(content_stream, width, height)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -3,41 +3,60 @@
|
|
|
3
3
|
require "chunky_png"
|
|
4
4
|
|
|
5
5
|
module AcroThat
|
|
6
|
-
module
|
|
7
|
-
#
|
|
8
|
-
class
|
|
6
|
+
module Fields
|
|
7
|
+
# Handles signature field creation
|
|
8
|
+
class Signature
|
|
9
9
|
include Base
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
attr_reader :field_obj_num
|
|
12
|
+
|
|
13
|
+
# Class method to add appearance to an existing signature field
|
|
14
|
+
# Can be called from both Signature field creation and UpdateField
|
|
15
|
+
def self.add_appearance(document, field_ref, image_data, width: nil, height: nil)
|
|
16
|
+
new(document, "", {}).add_appearance_to_field(field_ref, image_data, width: width, height: height)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def call
|
|
20
|
-
|
|
20
|
+
@field_obj_num = next_fresh_object_number
|
|
21
|
+
widget_obj_num = @field_obj_num + 1
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return false unless image_info
|
|
23
|
+
field_body = create_field_dictionary(@field_value, @field_type)
|
|
24
|
+
page_ref = find_page_ref(page_num)
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
widget_body = create_widget_annotation_with_parent(widget_obj_num, [@field_obj_num, 0], page_ref, x, y, width,
|
|
27
|
+
height, @field_type, @field_value)
|
|
28
|
+
|
|
29
|
+
document.instance_variable_get(:@patches) << { ref: [@field_obj_num, 0], body: field_body }
|
|
30
|
+
document.instance_variable_get(:@patches) << { ref: [widget_obj_num, 0], body: widget_body }
|
|
31
|
+
|
|
32
|
+
add_field_to_acroform_with_defaults(@field_obj_num)
|
|
33
|
+
add_widget_to_page(widget_obj_num, page_num)
|
|
34
|
+
|
|
35
|
+
# If this is a signature field with image data, add the signature appearance
|
|
36
|
+
if @field_value && !@field_value.empty?
|
|
37
|
+
image_data = @field_value
|
|
38
|
+
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}$})))
|
|
39
|
+
field_ref = [@field_obj_num, 0]
|
|
40
|
+
add_appearance_to_field(field_ref, image_data, width: width, height: height)
|
|
36
41
|
end
|
|
37
42
|
end
|
|
38
43
|
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def add_appearance_to_field(field_ref, image_data, width: nil, height: nil)
|
|
48
|
+
return false unless field_ref && image_data && !image_data.empty?
|
|
49
|
+
|
|
50
|
+
# Decode image data if needed
|
|
51
|
+
decoded_image_data = image_data.is_a?(String) && image_data.start_with?("data:") ? decode_base64_data_uri(image_data) : decode_base64_if_needed(image_data)
|
|
52
|
+
return false unless decoded_image_data && !decoded_image_data.empty?
|
|
53
|
+
|
|
54
|
+
# Detect image format and dimensions
|
|
55
|
+
image_info = detect_image_format(decoded_image_data)
|
|
56
|
+
return false unless image_info
|
|
57
|
+
|
|
58
|
+
# Find widget annotation for this field
|
|
59
|
+
widget_ref = find_signature_widget(field_ref)
|
|
41
60
|
return false unless widget_ref
|
|
42
61
|
|
|
43
62
|
widget_body = get_object_body_with_patch(widget_ref)
|
|
@@ -53,8 +72,8 @@ module AcroThat
|
|
|
53
72
|
return false if rect_width <= 0 || rect_height <= 0
|
|
54
73
|
|
|
55
74
|
# Get field dimensions (use provided width/height or field rect)
|
|
56
|
-
field_width =
|
|
57
|
-
field_height =
|
|
75
|
+
field_width = width || rect_width
|
|
76
|
+
field_height = height || rect_height
|
|
58
77
|
|
|
59
78
|
# Get image natural dimensions
|
|
60
79
|
image_width = image_info[:width].to_f
|
|
@@ -72,24 +91,27 @@ module AcroThat
|
|
|
72
91
|
|
|
73
92
|
# Create Image XObject(s) - use natural image dimensions (not scaled)
|
|
74
93
|
image_obj_num = next_fresh_object_number
|
|
75
|
-
image_result = create_image_xobject(image_obj_num,
|
|
94
|
+
image_result = create_image_xobject(image_obj_num, decoded_image_data, image_info, image_width, image_height)
|
|
76
95
|
image_body = image_result[:body]
|
|
77
96
|
mask_obj_num = image_result[:mask_obj_num]
|
|
78
97
|
|
|
79
98
|
# Create Form XObject (appearance stream) - use field dimensions for bounding box
|
|
80
|
-
# Image will be scaled and centered within the field bounds
|
|
81
99
|
form_obj_num = mask_obj_num ? mask_obj_num + 1 : image_obj_num + 1
|
|
82
100
|
form_body = create_form_xobject(form_obj_num, image_obj_num, field_width, field_height, scale_factor,
|
|
83
101
|
scaled_width, scaled_height)
|
|
84
102
|
|
|
85
103
|
# Queue new objects
|
|
86
|
-
patches << { ref: [image_obj_num, 0], body: image_body }
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
document.instance_variable_get(:@patches) << { ref: [image_obj_num, 0], body: image_body }
|
|
105
|
+
if mask_obj_num
|
|
106
|
+
document.instance_variable_get(:@patches) << { ref: [mask_obj_num, 0],
|
|
107
|
+
body: image_result[:mask_body] }
|
|
108
|
+
end
|
|
109
|
+
document.instance_variable_get(:@patches) << { ref: [form_obj_num, 0], body: form_body }
|
|
89
110
|
|
|
90
111
|
# Update widget annotation with /AP dictionary
|
|
91
|
-
#
|
|
92
|
-
|
|
112
|
+
# Use already-loaded widget_body as original (we already have it from line 62)
|
|
113
|
+
# Only reload if we don't have it (shouldn't happen, but for safety)
|
|
114
|
+
original_widget_body = widget_body || resolver.object_body(widget_ref)
|
|
93
115
|
updated_widget = add_appearance_to_widget(widget_body, form_obj_num)
|
|
94
116
|
apply_patch(widget_ref, updated_widget, original_widget_body)
|
|
95
117
|
|
|
@@ -99,14 +121,12 @@ module AcroThat
|
|
|
99
121
|
private
|
|
100
122
|
|
|
101
123
|
def decode_base64_data_uri(data_uri)
|
|
102
|
-
# Handle data:image/jpeg;base64,... format
|
|
103
124
|
if data_uri =~ %r{^data:image/[^;]+;base64,(.+)$}
|
|
104
125
|
Base64.decode64(Regexp.last_match(1))
|
|
105
126
|
end
|
|
106
127
|
end
|
|
107
128
|
|
|
108
129
|
def decode_base64_if_needed(data)
|
|
109
|
-
# Check if it looks like base64
|
|
110
130
|
if data.is_a?(String) && data.match?(%r{^[A-Za-z0-9+/]*={0,2}$})
|
|
111
131
|
begin
|
|
112
132
|
Base64.decode64(data)
|
|
@@ -128,7 +148,6 @@ module AcroThat
|
|
|
128
148
|
# PNG: starts with 89 50 4E 47 0D 0A 1A 0A
|
|
129
149
|
if data.bytesize >= 8 && data[0, 8] == "\x89PNG\r\n\x1A\n".b
|
|
130
150
|
width, height = extract_png_dimensions(data)
|
|
131
|
-
# NOTE: filter will be determined in create_image_xobject after PNG is decoded
|
|
132
151
|
return { format: :png, width: width, height: height, filter: "/FlateDecode" } if width && height
|
|
133
152
|
end
|
|
134
153
|
|
|
@@ -136,9 +155,8 @@ module AcroThat
|
|
|
136
155
|
end
|
|
137
156
|
|
|
138
157
|
def extract_jpeg_dimensions(data)
|
|
139
|
-
i = 2
|
|
158
|
+
i = 2
|
|
140
159
|
while i < data.bytesize - 9
|
|
141
|
-
# Look for SOF markers (0xFFC0, 0xFFC1, 0xFFC2, etc.)
|
|
142
160
|
if data.getbyte(i) == 0xFF && (data.getbyte(i + 1) & 0xF0) == 0xC0 && (i + 8 < data.bytesize)
|
|
143
161
|
height = (data.getbyte(i + 5) << 8) | data.getbyte(i + 6)
|
|
144
162
|
width = (data.getbyte(i + 7) << 8) | data.getbyte(i + 8)
|
|
@@ -146,13 +164,10 @@ module AcroThat
|
|
|
146
164
|
end
|
|
147
165
|
i += 1
|
|
148
166
|
end
|
|
149
|
-
# Fallback: if we can't find dimensions, return default 1x1 for minimal JPEGs
|
|
150
|
-
# This handles minimal/truncated JPEGs
|
|
151
167
|
[1, 1]
|
|
152
168
|
end
|
|
153
169
|
|
|
154
170
|
def extract_png_dimensions(data)
|
|
155
|
-
# PNG dimensions are in first IHDR chunk at offset 16
|
|
156
171
|
if data.bytesize >= 24
|
|
157
172
|
width = data[16, 4].unpack1("N")
|
|
158
173
|
height = data[20, 4].unpack1("N")
|
|
@@ -161,19 +176,17 @@ module AcroThat
|
|
|
161
176
|
nil
|
|
162
177
|
end
|
|
163
178
|
|
|
164
|
-
def
|
|
179
|
+
def find_signature_widget(field_ref)
|
|
165
180
|
# First check patches (for newly created widgets)
|
|
166
|
-
patches.each do |patch|
|
|
181
|
+
document.instance_variable_get(:@patches).each do |patch|
|
|
167
182
|
next unless patch[:body]
|
|
168
183
|
next unless DictScan.is_widget?(patch[:body])
|
|
169
184
|
|
|
170
|
-
# Check if widget has /Parent pointing to field_ref - use regex directly
|
|
171
185
|
if patch[:body] =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R}
|
|
172
186
|
parent_ref = [Integer(Regexp.last_match(1)), Integer(Regexp.last_match(2))]
|
|
173
187
|
return patch[:ref] if parent_ref == field_ref
|
|
174
188
|
end
|
|
175
189
|
|
|
176
|
-
# Also check if widget IS the field (flat structure) and has /FT /Sig
|
|
177
190
|
if patch[:body].include?("/FT") && DictScan.value_token_after("/FT",
|
|
178
191
|
patch[:body]) == "/Sig" && (patch[:ref] == field_ref)
|
|
179
192
|
return patch[:ref]
|
|
@@ -184,13 +197,11 @@ module AcroThat
|
|
|
184
197
|
resolver.each_object do |ref, body|
|
|
185
198
|
next unless body && DictScan.is_widget?(body)
|
|
186
199
|
|
|
187
|
-
# Check if widget has /Parent pointing to field_ref - use regex directly
|
|
188
200
|
if body =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R}
|
|
189
201
|
parent_ref = [Integer(Regexp.last_match(1)), Integer(Regexp.last_match(2))]
|
|
190
202
|
return ref if parent_ref == field_ref
|
|
191
203
|
end
|
|
192
204
|
|
|
193
|
-
# Also check if widget IS the field (flat structure) and has /FT /Sig
|
|
194
205
|
if body.include?("/FT") && DictScan.value_token_after("/FT", body) == "/Sig" && (ref == field_ref)
|
|
195
206
|
return ref
|
|
196
207
|
end
|
|
@@ -215,13 +226,6 @@ module AcroThat
|
|
|
215
226
|
{ x1: values[0], y1: values[1], x2: values[2], y2: values[3] }
|
|
216
227
|
end
|
|
217
228
|
|
|
218
|
-
def get_widget_rect_dimensions(widget_body)
|
|
219
|
-
rect = extract_rect(widget_body)
|
|
220
|
-
return nil unless rect
|
|
221
|
-
|
|
222
|
-
{ width: rect[:x2] - rect[:x1], height: rect[:y2] - rect[:y1] }
|
|
223
|
-
end
|
|
224
|
-
|
|
225
229
|
def create_image_xobject(obj_num, image_data, image_info, _width, _height)
|
|
226
230
|
stream_data = nil
|
|
227
231
|
filter = image_info[:filter]
|
|
@@ -230,19 +234,14 @@ module AcroThat
|
|
|
230
234
|
|
|
231
235
|
case image_info[:format]
|
|
232
236
|
when :jpeg
|
|
233
|
-
# JPEG can be embedded directly with DCTDecode (no transparency support in JPEG)
|
|
234
237
|
stream_data = image_data.b
|
|
235
238
|
when :png
|
|
236
|
-
# PNG needs to be decoded to raw RGB pixel data
|
|
237
239
|
begin
|
|
238
240
|
png = ChunkyPNG::Image.from_io(StringIO.new(image_data))
|
|
239
241
|
|
|
240
|
-
# Check if image has transparency
|
|
241
242
|
has_transparency = if png.palette
|
|
242
|
-
# Indexed color PNG - check if palette has transparent color
|
|
243
243
|
png.palette.include?(ChunkyPNG::Color::TRANSPARENT)
|
|
244
244
|
else
|
|
245
|
-
# True color PNG - check if any pixel has alpha < 255
|
|
246
245
|
sample_size = [png.width * png.height, 1000].min
|
|
247
246
|
step = [png.width * png.height / sample_size, 1].max
|
|
248
247
|
has_alpha = false
|
|
@@ -261,20 +260,17 @@ module AcroThat
|
|
|
261
260
|
width = png.width
|
|
262
261
|
height = png.height
|
|
263
262
|
|
|
264
|
-
# Extract RGB pixel data (without compositing)
|
|
265
263
|
rgb_data = +""
|
|
266
264
|
alpha_data = +"" if has_transparency
|
|
267
265
|
|
|
268
266
|
height.times do |y|
|
|
269
267
|
width.times do |x|
|
|
270
268
|
color = png[x, y]
|
|
271
|
-
# Extract RGB components
|
|
272
269
|
r = ChunkyPNG::Color.r(color)
|
|
273
270
|
g = ChunkyPNG::Color.g(color)
|
|
274
271
|
b = ChunkyPNG::Color.b(color)
|
|
275
272
|
rgb_data << [r, g, b].pack("C*")
|
|
276
273
|
|
|
277
|
-
# Extract alpha channel for soft mask
|
|
278
274
|
if has_transparency
|
|
279
275
|
alpha = ChunkyPNG::Color.a(color)
|
|
280
276
|
alpha_data << [alpha].pack("C*")
|
|
@@ -282,11 +278,9 @@ module AcroThat
|
|
|
282
278
|
end
|
|
283
279
|
end
|
|
284
280
|
|
|
285
|
-
# Compress RGB data with FlateDecode
|
|
286
281
|
stream_data = Zlib::Deflate.deflate(rgb_data)
|
|
287
282
|
filter = "/FlateDecode"
|
|
288
283
|
|
|
289
|
-
# Create soft mask (alpha channel) if transparency is present
|
|
290
284
|
if has_transparency && alpha_data
|
|
291
285
|
mask_obj_num = obj_num + 1
|
|
292
286
|
compressed_alpha = Zlib::Deflate.deflate(alpha_data)
|
|
@@ -307,7 +301,6 @@ module AcroThat
|
|
|
307
301
|
mask_body += "\nendstream"
|
|
308
302
|
end
|
|
309
303
|
rescue StandardError
|
|
310
|
-
# If PNG decoding fails, fall back to trying raw data (won't work but prevents crash)
|
|
311
304
|
stream_data = image_data.b
|
|
312
305
|
end
|
|
313
306
|
else
|
|
@@ -325,7 +318,6 @@ module AcroThat
|
|
|
325
318
|
dict += " /ColorSpace /DeviceRGB\n"
|
|
326
319
|
dict += " /Filter #{filter}\n"
|
|
327
320
|
dict += " /Length #{stream_length}\n"
|
|
328
|
-
# Add soft mask if transparency is present
|
|
329
321
|
dict += " /SMask #{mask_obj_num} 0 R\n" if mask_obj_num
|
|
330
322
|
dict += ">>\n"
|
|
331
323
|
dict += "stream\n"
|
|
@@ -337,23 +329,11 @@ module AcroThat
|
|
|
337
329
|
|
|
338
330
|
def create_form_xobject(_obj_num, image_obj_num, field_width, field_height, _scale_factor, scaled_width,
|
|
339
331
|
scaled_height)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
# PDF content stream that draws the image
|
|
345
|
-
# q = save graphics state
|
|
346
|
-
# In PDF, when you draw an Image XObject with /Im1 Do, it draws a 1x1 unit square
|
|
347
|
-
# The /Width and /Height in the Image XObject define the pixel dimensions, not user space size
|
|
348
|
-
# To scale it to the desired size, we scale by scaled_width x scaled_height
|
|
349
|
-
# Then translate to center it within the field bounds
|
|
350
|
-
# Transformation matrix: [sx 0 0 sy tx ty] where sx=scaled_width, sy=scaled_height
|
|
351
|
-
# Format: [a b c d e f] where a=sx, d=sy, e=tx, f=ty
|
|
352
|
-
# Q = restore graphics state
|
|
332
|
+
offset_x = 0.0
|
|
333
|
+
offset_y = (field_height - scaled_height) / 2.0
|
|
334
|
+
|
|
353
335
|
content_stream = "q\n"
|
|
354
|
-
# First translate to center position
|
|
355
336
|
content_stream += "1 0 0 1 #{offset_x} #{offset_y} cm\n" if offset_x != 0 || offset_y != 0
|
|
356
|
-
# Then scale by scaled dimensions (this makes the 1x1 unit image become scaled_width x scaled_height)
|
|
357
337
|
content_stream += "#{scaled_width} 0 0 #{scaled_height} 0 0 cm\n"
|
|
358
338
|
content_stream += "/Im1 Do\n"
|
|
359
339
|
content_stream += "Q"
|
|
@@ -373,22 +353,15 @@ module AcroThat
|
|
|
373
353
|
end
|
|
374
354
|
|
|
375
355
|
def add_appearance_to_widget(widget_body, form_obj_num)
|
|
376
|
-
# Add /AP << /N form_obj_num 0 R >> to widget
|
|
377
|
-
ap_entry = "/AP << /N #{form_obj_num} 0 R >>"
|
|
378
356
|
new_ap_value = "<< /N #{form_obj_num} 0 R >>"
|
|
379
357
|
|
|
380
358
|
if widget_body.include?("/AP")
|
|
381
|
-
# Extract the full /AP dictionary value (not just the opening <<)
|
|
382
|
-
# Find /AP key
|
|
383
359
|
ap_key_match = widget_body.match(%r{/AP(?=[\s(<\[/])})
|
|
384
360
|
return widget_body unless ap_key_match
|
|
385
361
|
|
|
386
|
-
# Find the start of the value (after /AP and whitespace)
|
|
387
362
|
value_start = ap_key_match.end(0)
|
|
388
363
|
value_start += 1 while value_start < widget_body.length && widget_body[value_start] =~ /\s/
|
|
389
364
|
|
|
390
|
-
# Extract the complete nested dictionary value
|
|
391
|
-
# Start with << and track depth to find matching >>
|
|
392
365
|
depth = 0
|
|
393
366
|
value_end = value_start
|
|
394
367
|
while value_end < widget_body.length
|
|
@@ -404,17 +377,15 @@ module AcroThat
|
|
|
404
377
|
end
|
|
405
378
|
end
|
|
406
379
|
|
|
407
|
-
# Replace the complete /AP value
|
|
408
380
|
before = widget_body[0...value_start]
|
|
409
381
|
after = widget_body[value_end..]
|
|
410
382
|
return "#{before}#{new_ap_value}#{after}"
|
|
411
383
|
end
|
|
412
384
|
|
|
413
|
-
# Insert /AP before closing >>
|
|
414
385
|
if widget_body.include?(">>")
|
|
415
|
-
widget_body.sub(/(\s*)>>\s*$/, "\\1#{
|
|
386
|
+
widget_body.sub(/(\s*)>>\s*$/, "\\1/AP #{new_ap_value}\n\\1>>")
|
|
416
387
|
else
|
|
417
|
-
widget_body + " #{
|
|
388
|
+
widget_body + " /AP #{new_ap_value}"
|
|
418
389
|
end
|
|
419
390
|
end
|
|
420
391
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AcroThat
|
|
4
|
+
module Fields
|
|
5
|
+
# Handles text field creation
|
|
6
|
+
class Text
|
|
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
|
+
true
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/acro_that/version.rb
CHANGED
data/lib/acro_that.rb
CHANGED
|
@@ -15,12 +15,20 @@ require_relative "acro_that/field"
|
|
|
15
15
|
require_relative "acro_that/page"
|
|
16
16
|
require_relative "acro_that/document"
|
|
17
17
|
|
|
18
|
-
# Load actions
|
|
18
|
+
# Load actions base first (needed by fields)
|
|
19
19
|
require_relative "acro_that/actions/base"
|
|
20
|
+
|
|
21
|
+
# Load fields
|
|
22
|
+
require_relative "acro_that/fields/base"
|
|
23
|
+
require_relative "acro_that/fields/radio"
|
|
24
|
+
require_relative "acro_that/fields/text"
|
|
25
|
+
require_relative "acro_that/fields/checkbox"
|
|
26
|
+
require_relative "acro_that/fields/signature"
|
|
27
|
+
|
|
28
|
+
# Load actions
|
|
20
29
|
require_relative "acro_that/actions/add_field"
|
|
21
30
|
require_relative "acro_that/actions/update_field"
|
|
22
31
|
require_relative "acro_that/actions/remove_field"
|
|
23
|
-
require_relative "acro_that/actions/add_signature_appearance"
|
|
24
32
|
|
|
25
33
|
module AcroThat
|
|
26
34
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: acro_that
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michael Wynkoop
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-11-
|
|
11
|
+
date: 2025-11-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: chunky_png
|
|
@@ -108,13 +108,17 @@ files:
|
|
|
108
108
|
- issues/refactoring-opportunities.md
|
|
109
109
|
- lib/acro_that.rb
|
|
110
110
|
- lib/acro_that/actions/add_field.rb
|
|
111
|
-
- lib/acro_that/actions/add_signature_appearance.rb
|
|
112
111
|
- lib/acro_that/actions/base.rb
|
|
113
112
|
- lib/acro_that/actions/remove_field.rb
|
|
114
113
|
- lib/acro_that/actions/update_field.rb
|
|
115
114
|
- lib/acro_that/dict_scan.rb
|
|
116
115
|
- lib/acro_that/document.rb
|
|
117
116
|
- lib/acro_that/field.rb
|
|
117
|
+
- lib/acro_that/fields/base.rb
|
|
118
|
+
- lib/acro_that/fields/checkbox.rb
|
|
119
|
+
- lib/acro_that/fields/radio.rb
|
|
120
|
+
- lib/acro_that/fields/signature.rb
|
|
121
|
+
- lib/acro_that/fields/text.rb
|
|
118
122
|
- lib/acro_that/incremental_writer.rb
|
|
119
123
|
- lib/acro_that/object_resolver.rb
|
|
120
124
|
- lib/acro_that/objstm.rb
|