acro_that 0.1.8 → 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.
@@ -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 Actions
7
- # Action to add image appearance to a signature field
8
- class AddSignatureAppearance
6
+ module Fields
7
+ # Handles signature field creation
8
+ class Signature
9
9
  include Base
10
10
 
11
- def initialize(document, field_ref, image_data, width: nil, height: nil)
12
- @document = document
13
- @field_ref = field_ref
14
- @image_data = image_data.is_a?(String) && image_data.start_with?("data:") ? decode_base64_data_uri(image_data) : decode_base64_if_needed(image_data)
15
- @width = width
16
- @height = height
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
- return false unless @field_ref && @image_data && !@image_data.empty?
20
+ @field_obj_num = next_fresh_object_number
21
+ widget_obj_num = @field_obj_num + 1
21
22
 
22
- # Detect image format and dimensions
23
- image_info = detect_image_format(@image_data)
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
- # Find widget annotation for this field
27
- # First try: widget might be at field_obj_num + 1 (common pattern from AddField)
28
- widget_ref = nil
29
- if @field_ref[0] && @field_ref[1].zero?
30
- potential_widget_ref = [@field_ref[0] + 1, 0]
31
- potential_body = get_object_body_with_patch(potential_widget_ref)
32
- # Check if it has /Parent pointing to our field - use regex directly for object references
33
- if potential_body && DictScan.is_widget?(potential_body) && (potential_body =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R})
34
- parent_ref = [Integer(Regexp.last_match(1)), Integer(Regexp.last_match(2))]
35
- widget_ref = potential_widget_ref if parent_ref == @field_ref
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
- # If not found, use general search
40
- widget_ref ||= find_widget_annotation(@field_ref)
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 = @width || rect_width
57
- field_height = @height || rect_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, @image_data, image_info, image_width, image_height)
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
- patches << { ref: [mask_obj_num, 0], body: image_result[:mask_body] } if mask_obj_num
88
- patches << { ref: [form_obj_num, 0], body: form_body }
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
- # Get original body from resolver (not from patches) to ensure comparison works
92
- original_widget_body = resolver.object_body(widget_ref)
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 # Skip SOI marker
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 find_widget_annotation(field_ref)
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
- # Calculate offset to left-align the image horizontally and center vertically
341
- offset_x = 0.0 # Left-aligned (no horizontal offset)
342
- offset_y = (field_height - scaled_height) / 2.0 # Center vertically
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#{ap_entry}\n\\1>>")
386
+ widget_body.sub(/(\s*)>>\s*$/, "\\1/AP #{new_ap_value}\n\\1>>")
416
387
  else
417
- widget_body + " #{ap_entry}"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AcroThat
4
- VERSION = "0.1.8"
4
+ VERSION = "1.0.0"
5
5
  end
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.1.8
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-04 00:00:00.000000000 Z
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