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,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CorpPdf
|
|
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.to_s
|
|
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.to_s
|
|
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.to_s
|
|
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
|
+
# Define checkmark in normalized coordinates (0-1 range) for consistent aspect ratio
|
|
169
|
+
# Checkmark shape: three points forming a checkmark
|
|
170
|
+
norm_x1 = 0.25
|
|
171
|
+
norm_y1 = 0.55
|
|
172
|
+
norm_x2 = 0.45
|
|
173
|
+
norm_y2 = 0.35
|
|
174
|
+
norm_x3 = 0.75
|
|
175
|
+
norm_y3 = 0.85
|
|
176
|
+
|
|
177
|
+
# Calculate scale to maximize size while maintaining aspect ratio
|
|
178
|
+
# Use the smaller dimension to ensure it fits
|
|
179
|
+
scale = [width, height].min * 0.85 # Use 85% of the smaller dimension
|
|
180
|
+
|
|
181
|
+
# Calculate checkmark dimensions
|
|
182
|
+
check_width = scale
|
|
183
|
+
check_height = scale
|
|
184
|
+
|
|
185
|
+
# Center the checkmark in the box
|
|
186
|
+
offset_x = (width - check_width) / 2
|
|
187
|
+
offset_y = (height - check_height) / 2
|
|
188
|
+
|
|
189
|
+
# Calculate actual coordinates
|
|
190
|
+
check_x1 = offset_x + (norm_x1 * check_width)
|
|
191
|
+
check_y1 = offset_y + (norm_y1 * check_height)
|
|
192
|
+
check_x2 = offset_x + (norm_x2 * check_width)
|
|
193
|
+
check_y2 = offset_y + (norm_y2 * check_height)
|
|
194
|
+
check_x3 = offset_x + (norm_x3 * check_width)
|
|
195
|
+
check_y3 = offset_y + (norm_y3 * check_height)
|
|
196
|
+
|
|
197
|
+
content_stream = "q\n"
|
|
198
|
+
# Draw checkmark only (no border)
|
|
199
|
+
content_stream += "0 0 0 rg\n" # Black fill color
|
|
200
|
+
content_stream += "#{border_width} w\n" # Line width for checkmark
|
|
201
|
+
content_stream += "#{check_x1} #{check_y1} m\n"
|
|
202
|
+
content_stream += "#{check_x2} #{check_y2} l\n"
|
|
203
|
+
content_stream += "#{check_x3} #{check_y3} l\n"
|
|
204
|
+
content_stream += "S\n" # Stroke the checkmark
|
|
205
|
+
content_stream += "Q\n"
|
|
206
|
+
|
|
207
|
+
build_form_xobject(content_stream, width, height)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def create_radio_unchecked_appearance(width, height)
|
|
211
|
+
# Empty appearance (no border, no checkmark)
|
|
212
|
+
content_stream = "q\n"
|
|
213
|
+
# Empty appearance for unchecked state
|
|
214
|
+
content_stream += "Q\n"
|
|
215
|
+
|
|
216
|
+
build_form_xobject(content_stream, width, height)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "chunky_png"
|
|
4
|
+
|
|
5
|
+
module CorpPdf
|
|
6
|
+
module Fields
|
|
7
|
+
# Handles signature field creation
|
|
8
|
+
class Signature
|
|
9
|
+
include Base
|
|
10
|
+
|
|
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
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
@field_obj_num = next_fresh_object_number
|
|
21
|
+
widget_obj_num = @field_obj_num + 1
|
|
22
|
+
|
|
23
|
+
field_body = create_field_dictionary(@field_value, @field_type)
|
|
24
|
+
page_ref = find_page_ref(page_num)
|
|
25
|
+
|
|
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)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
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)
|
|
60
|
+
return false unless widget_ref
|
|
61
|
+
|
|
62
|
+
widget_body = get_object_body_with_patch(widget_ref)
|
|
63
|
+
return false unless widget_body
|
|
64
|
+
|
|
65
|
+
# Get widget rectangle for appearance size
|
|
66
|
+
rect = extract_rect(widget_body)
|
|
67
|
+
return false unless rect
|
|
68
|
+
|
|
69
|
+
# Ensure width and height are positive
|
|
70
|
+
rect_width = (rect[:x2] - rect[:x1]).abs
|
|
71
|
+
rect_height = (rect[:y2] - rect[:y1]).abs
|
|
72
|
+
return false if rect_width <= 0 || rect_height <= 0
|
|
73
|
+
|
|
74
|
+
# Get field dimensions (use provided width/height or field rect)
|
|
75
|
+
field_width = width || rect_width
|
|
76
|
+
field_height = height || rect_height
|
|
77
|
+
|
|
78
|
+
# Get image natural dimensions
|
|
79
|
+
image_width = image_info[:width].to_f
|
|
80
|
+
image_height = image_info[:height].to_f
|
|
81
|
+
return false if image_width <= 0 || image_height <= 0
|
|
82
|
+
|
|
83
|
+
# Calculate scaling factor to fit image within field while maintaining aspect ratio
|
|
84
|
+
scale_x = field_width / image_width
|
|
85
|
+
scale_y = field_height / image_height
|
|
86
|
+
scale_factor = [scale_x, scale_y].min
|
|
87
|
+
|
|
88
|
+
# Calculate scaled dimensions (maintains aspect ratio, fits within field)
|
|
89
|
+
scaled_width = image_width * scale_factor
|
|
90
|
+
scaled_height = image_height * scale_factor
|
|
91
|
+
|
|
92
|
+
# Create Image XObject(s) - use natural image dimensions (not scaled)
|
|
93
|
+
image_obj_num = next_fresh_object_number
|
|
94
|
+
image_result = create_image_xobject(image_obj_num, decoded_image_data, image_info, image_width, image_height)
|
|
95
|
+
image_body = image_result[:body]
|
|
96
|
+
mask_obj_num = image_result[:mask_obj_num]
|
|
97
|
+
|
|
98
|
+
# Create Form XObject (appearance stream) - use field dimensions for bounding box
|
|
99
|
+
form_obj_num = mask_obj_num ? mask_obj_num + 1 : image_obj_num + 1
|
|
100
|
+
form_body = create_form_xobject(form_obj_num, image_obj_num, field_width, field_height, scale_factor,
|
|
101
|
+
scaled_width, scaled_height)
|
|
102
|
+
|
|
103
|
+
# Queue new objects
|
|
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 }
|
|
110
|
+
|
|
111
|
+
# Update widget annotation with /AP dictionary
|
|
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)
|
|
115
|
+
updated_widget = add_appearance_to_widget(widget_body, form_obj_num)
|
|
116
|
+
apply_patch(widget_ref, updated_widget, original_widget_body)
|
|
117
|
+
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def decode_base64_data_uri(data_uri)
|
|
124
|
+
if data_uri =~ %r{^data:image/[^;]+;base64,(.+)$}
|
|
125
|
+
Base64.decode64(Regexp.last_match(1))
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def decode_base64_if_needed(data)
|
|
130
|
+
if data.is_a?(String) && data.match?(%r{^[A-Za-z0-9+/]*={0,2}$})
|
|
131
|
+
begin
|
|
132
|
+
Base64.decode64(data)
|
|
133
|
+
rescue StandardError
|
|
134
|
+
data.b
|
|
135
|
+
end
|
|
136
|
+
else
|
|
137
|
+
data.is_a?(String) ? data.b : data
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def detect_image_format(data)
|
|
142
|
+
# JPEG: starts with FF D8 FF
|
|
143
|
+
if data.bytesize >= 3 && data.getbyte(0) == 0xFF && data.getbyte(1) == 0xD8 && data.getbyte(2) == 0xFF
|
|
144
|
+
width, height = extract_jpeg_dimensions(data)
|
|
145
|
+
return { format: :jpeg, width: width, height: height, filter: "/DCTDecode" } if width && height
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# PNG: starts with 89 50 4E 47 0D 0A 1A 0A
|
|
149
|
+
if data.bytesize >= 8 && data[0, 8] == "\x89PNG\r\n\x1A\n".b
|
|
150
|
+
width, height = extract_png_dimensions(data)
|
|
151
|
+
return { format: :png, width: width, height: height, filter: "/FlateDecode" } if width && height
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def extract_jpeg_dimensions(data)
|
|
158
|
+
i = 2
|
|
159
|
+
while i < data.bytesize - 9
|
|
160
|
+
if data.getbyte(i) == 0xFF && (data.getbyte(i + 1) & 0xF0) == 0xC0 && (i + 8 < data.bytesize)
|
|
161
|
+
height = (data.getbyte(i + 5) << 8) | data.getbyte(i + 6)
|
|
162
|
+
width = (data.getbyte(i + 7) << 8) | data.getbyte(i + 8)
|
|
163
|
+
return [width, height] if width.positive? && height.positive?
|
|
164
|
+
end
|
|
165
|
+
i += 1
|
|
166
|
+
end
|
|
167
|
+
[1, 1]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def extract_png_dimensions(data)
|
|
171
|
+
if data.bytesize >= 24
|
|
172
|
+
width = data[16, 4].unpack1("N")
|
|
173
|
+
height = data[20, 4].unpack1("N")
|
|
174
|
+
return [width, height]
|
|
175
|
+
end
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def find_signature_widget(field_ref)
|
|
180
|
+
# First check patches (for newly created widgets)
|
|
181
|
+
document.instance_variable_get(:@patches).each do |patch|
|
|
182
|
+
next unless patch[:body]
|
|
183
|
+
next unless DictScan.is_widget?(patch[:body])
|
|
184
|
+
|
|
185
|
+
if patch[:body] =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R}
|
|
186
|
+
parent_ref = [Integer(Regexp.last_match(1)), Integer(Regexp.last_match(2))]
|
|
187
|
+
return patch[:ref] if parent_ref == field_ref
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
if patch[:body].include?("/FT") && DictScan.value_token_after("/FT",
|
|
191
|
+
patch[:body]) == "/Sig" && (patch[:ref] == field_ref)
|
|
192
|
+
return patch[:ref]
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Then check resolver (for existing widgets)
|
|
197
|
+
resolver.each_object do |ref, body|
|
|
198
|
+
next unless body && DictScan.is_widget?(body)
|
|
199
|
+
|
|
200
|
+
if body =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R}
|
|
201
|
+
parent_ref = [Integer(Regexp.last_match(1)), Integer(Regexp.last_match(2))]
|
|
202
|
+
return ref if parent_ref == field_ref
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
if body.include?("/FT") && DictScan.value_token_after("/FT", body) == "/Sig" && (ref == field_ref)
|
|
206
|
+
return ref
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Fallback: if field_ref itself is a widget
|
|
211
|
+
body = get_object_body_with_patch(field_ref)
|
|
212
|
+
return field_ref if body && DictScan.is_widget?(body) && body.include?("/FT") && DictScan.value_token_after(
|
|
213
|
+
"/FT", body
|
|
214
|
+
) == "/Sig"
|
|
215
|
+
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def extract_rect(widget_body)
|
|
220
|
+
rect_tok = DictScan.value_token_after("/Rect", widget_body)
|
|
221
|
+
return nil unless rect_tok && rect_tok.start_with?("[")
|
|
222
|
+
|
|
223
|
+
values = rect_tok.scan(/[-+]?\d*\.?\d+/).map(&:to_f)
|
|
224
|
+
return nil unless values.length == 4
|
|
225
|
+
|
|
226
|
+
{ x1: values[0], y1: values[1], x2: values[2], y2: values[3] }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def create_image_xobject(obj_num, image_data, image_info, _width, _height)
|
|
230
|
+
stream_data = nil
|
|
231
|
+
filter = image_info[:filter]
|
|
232
|
+
mask_obj_num = nil
|
|
233
|
+
mask_body = nil
|
|
234
|
+
|
|
235
|
+
case image_info[:format]
|
|
236
|
+
when :jpeg
|
|
237
|
+
stream_data = image_data.b
|
|
238
|
+
when :png
|
|
239
|
+
begin
|
|
240
|
+
png = ChunkyPNG::Image.from_io(StringIO.new(image_data))
|
|
241
|
+
|
|
242
|
+
has_transparency = if png.palette
|
|
243
|
+
png.palette.include?(ChunkyPNG::Color::TRANSPARENT)
|
|
244
|
+
else
|
|
245
|
+
sample_size = [png.width * png.height, 1000].min
|
|
246
|
+
step = [png.width * png.height / sample_size, 1].max
|
|
247
|
+
has_alpha = false
|
|
248
|
+
(0...(png.width * png.height)).step(step) do |i|
|
|
249
|
+
x = i % png.width
|
|
250
|
+
y = i / png.width
|
|
251
|
+
alpha = ChunkyPNG::Color.a(png[x, y])
|
|
252
|
+
if alpha < 255
|
|
253
|
+
has_alpha = true
|
|
254
|
+
break
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
has_alpha
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
width = png.width
|
|
261
|
+
height = png.height
|
|
262
|
+
|
|
263
|
+
rgb_data = +""
|
|
264
|
+
alpha_data = +"" if has_transparency
|
|
265
|
+
|
|
266
|
+
height.times do |y|
|
|
267
|
+
width.times do |x|
|
|
268
|
+
color = png[x, y]
|
|
269
|
+
r = ChunkyPNG::Color.r(color)
|
|
270
|
+
g = ChunkyPNG::Color.g(color)
|
|
271
|
+
b = ChunkyPNG::Color.b(color)
|
|
272
|
+
rgb_data << [r, g, b].pack("C*")
|
|
273
|
+
|
|
274
|
+
if has_transparency
|
|
275
|
+
alpha = ChunkyPNG::Color.a(color)
|
|
276
|
+
alpha_data << [alpha].pack("C*")
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
stream_data = Zlib::Deflate.deflate(rgb_data)
|
|
282
|
+
filter = "/FlateDecode"
|
|
283
|
+
|
|
284
|
+
if has_transparency && alpha_data
|
|
285
|
+
mask_obj_num = obj_num + 1
|
|
286
|
+
compressed_alpha = Zlib::Deflate.deflate(alpha_data)
|
|
287
|
+
mask_length = compressed_alpha.bytesize
|
|
288
|
+
|
|
289
|
+
mask_body = "<<\n"
|
|
290
|
+
mask_body += " /Type /XObject\n"
|
|
291
|
+
mask_body += " /Subtype /Image\n"
|
|
292
|
+
mask_body += " /Width #{width}\n"
|
|
293
|
+
mask_body += " /Height #{height}\n"
|
|
294
|
+
mask_body += " /BitsPerComponent 8\n"
|
|
295
|
+
mask_body += " /ColorSpace /DeviceGray\n"
|
|
296
|
+
mask_body += " /Filter /FlateDecode\n"
|
|
297
|
+
mask_body += " /Length #{mask_length}\n"
|
|
298
|
+
mask_body += ">>\n"
|
|
299
|
+
mask_body += "stream\n"
|
|
300
|
+
mask_body += compressed_alpha
|
|
301
|
+
mask_body += "\nendstream"
|
|
302
|
+
end
|
|
303
|
+
rescue StandardError
|
|
304
|
+
stream_data = image_data.b
|
|
305
|
+
end
|
|
306
|
+
else
|
|
307
|
+
stream_data = image_data.b
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
stream_length = stream_data.bytesize
|
|
311
|
+
|
|
312
|
+
dict = "<<\n"
|
|
313
|
+
dict += " /Type /XObject\n"
|
|
314
|
+
dict += " /Subtype /Image\n"
|
|
315
|
+
dict += " /Width #{image_info[:width]}\n"
|
|
316
|
+
dict += " /Height #{image_info[:height]}\n"
|
|
317
|
+
dict += " /BitsPerComponent 8\n"
|
|
318
|
+
dict += " /ColorSpace /DeviceRGB\n"
|
|
319
|
+
dict += " /Filter #{filter}\n"
|
|
320
|
+
dict += " /Length #{stream_length}\n"
|
|
321
|
+
dict += " /SMask #{mask_obj_num} 0 R\n" if mask_obj_num
|
|
322
|
+
dict += ">>\n"
|
|
323
|
+
dict += "stream\n"
|
|
324
|
+
dict += stream_data
|
|
325
|
+
dict += "\nendstream"
|
|
326
|
+
|
|
327
|
+
{ body: dict, mask_obj_num: mask_obj_num, mask_body: mask_body }
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def create_form_xobject(_obj_num, image_obj_num, field_width, field_height, _scale_factor, scaled_width,
|
|
331
|
+
scaled_height)
|
|
332
|
+
offset_x = 0.0
|
|
333
|
+
offset_y = (field_height - scaled_height) / 2.0
|
|
334
|
+
|
|
335
|
+
content_stream = "q\n"
|
|
336
|
+
content_stream += "1 0 0 1 #{offset_x} #{offset_y} cm\n" if offset_x != 0 || offset_y != 0
|
|
337
|
+
content_stream += "#{scaled_width} 0 0 #{scaled_height} 0 0 cm\n"
|
|
338
|
+
content_stream += "/Im1 Do\n"
|
|
339
|
+
content_stream += "Q"
|
|
340
|
+
|
|
341
|
+
dict = "<<\n"
|
|
342
|
+
dict += " /Type /XObject\n"
|
|
343
|
+
dict += " /Subtype /Form\n"
|
|
344
|
+
dict += " /BBox [0 0 #{field_width} #{field_height}]\n"
|
|
345
|
+
dict += " /Resources << /XObject << /Im1 #{image_obj_num} 0 R >> >>\n"
|
|
346
|
+
dict += " /Length #{content_stream.bytesize}\n"
|
|
347
|
+
dict += ">>\n"
|
|
348
|
+
dict += "stream\n"
|
|
349
|
+
dict += content_stream
|
|
350
|
+
dict += "\nendstream"
|
|
351
|
+
|
|
352
|
+
dict
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def add_appearance_to_widget(widget_body, form_obj_num)
|
|
356
|
+
new_ap_value = "<< /N #{form_obj_num} 0 R >>"
|
|
357
|
+
|
|
358
|
+
if widget_body.include?("/AP")
|
|
359
|
+
ap_key_match = widget_body.match(%r{/AP(?=[\s(<\[/])})
|
|
360
|
+
return widget_body unless ap_key_match
|
|
361
|
+
|
|
362
|
+
value_start = ap_key_match.end(0)
|
|
363
|
+
value_start += 1 while value_start < widget_body.length && widget_body[value_start] =~ /\s/
|
|
364
|
+
|
|
365
|
+
depth = 0
|
|
366
|
+
value_end = value_start
|
|
367
|
+
while value_end < widget_body.length
|
|
368
|
+
if widget_body[value_end, 2] == "<<"
|
|
369
|
+
depth += 1
|
|
370
|
+
value_end += 2
|
|
371
|
+
elsif widget_body[value_end, 2] == ">>"
|
|
372
|
+
depth -= 1
|
|
373
|
+
value_end += 2
|
|
374
|
+
break if depth.zero?
|
|
375
|
+
else
|
|
376
|
+
value_end += 1
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
before = widget_body[0...value_start]
|
|
381
|
+
after = widget_body[value_end..]
|
|
382
|
+
return "#{before}#{new_ap_value}#{after}"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
if widget_body.include?(">>")
|
|
386
|
+
widget_body.sub(/(\s*)>>\s*$/, "\\1/AP #{new_ap_value}\n\\1>>")
|
|
387
|
+
else
|
|
388
|
+
widget_body + " /AP #{new_ap_value}"
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CorpPdf
|
|
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
|