acro_that 0.1.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,422 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "chunky_png"
4
+
5
+ module AcroThat
6
+ module Actions
7
+ # Action to add image appearance to a signature field
8
+ class AddSignatureAppearance
9
+ include Base
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
17
+ end
18
+
19
+ def call
20
+ return false unless @field_ref && @image_data && !@image_data.empty?
21
+
22
+ # Detect image format and dimensions
23
+ image_info = detect_image_format(@image_data)
24
+ return false unless image_info
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
36
+ end
37
+ end
38
+
39
+ # If not found, use general search
40
+ widget_ref ||= find_widget_annotation(@field_ref)
41
+ return false unless widget_ref
42
+
43
+ widget_body = get_object_body_with_patch(widget_ref)
44
+ return false unless widget_body
45
+
46
+ # Get widget rectangle for appearance size
47
+ rect = extract_rect(widget_body)
48
+ return false unless rect
49
+
50
+ # Ensure width and height are positive
51
+ rect_width = (rect[:x2] - rect[:x1]).abs
52
+ rect_height = (rect[:y2] - rect[:y1]).abs
53
+ return false if rect_width <= 0 || rect_height <= 0
54
+
55
+ # Get field dimensions (use provided width/height or field rect)
56
+ field_width = @width || rect_width
57
+ field_height = @height || rect_height
58
+
59
+ # Get image natural dimensions
60
+ image_width = image_info[:width].to_f
61
+ image_height = image_info[:height].to_f
62
+ return false if image_width <= 0 || image_height <= 0
63
+
64
+ # Calculate scaling factor to fit image within field while maintaining aspect ratio
65
+ scale_x = field_width / image_width
66
+ scale_y = field_height / image_height
67
+ scale_factor = [scale_x, scale_y].min
68
+
69
+ # Calculate scaled dimensions (maintains aspect ratio, fits within field)
70
+ scaled_width = image_width * scale_factor
71
+ scaled_height = image_height * scale_factor
72
+
73
+ # Create Image XObject(s) - use natural image dimensions (not scaled)
74
+ image_obj_num = next_fresh_object_number
75
+ image_result = create_image_xobject(image_obj_num, @image_data, image_info, image_width, image_height)
76
+ image_body = image_result[:body]
77
+ mask_obj_num = image_result[:mask_obj_num]
78
+
79
+ # Create Form XObject (appearance stream) - use field dimensions for bounding box
80
+ # Image will be scaled and centered within the field bounds
81
+ form_obj_num = mask_obj_num ? mask_obj_num + 1 : image_obj_num + 1
82
+ form_body = create_form_xobject(form_obj_num, image_obj_num, field_width, field_height, scale_factor,
83
+ scaled_width, scaled_height)
84
+
85
+ # 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 }
89
+
90
+ # 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)
93
+ updated_widget = add_appearance_to_widget(widget_body, form_obj_num)
94
+ apply_patch(widget_ref, updated_widget, original_widget_body)
95
+
96
+ true
97
+ end
98
+
99
+ private
100
+
101
+ def decode_base64_data_uri(data_uri)
102
+ # Handle data:image/jpeg;base64,... format
103
+ if data_uri =~ %r{^data:image/[^;]+;base64,(.+)$}
104
+ Base64.decode64(Regexp.last_match(1))
105
+ end
106
+ end
107
+
108
+ def decode_base64_if_needed(data)
109
+ # Check if it looks like base64
110
+ if data.is_a?(String) && data.match?(%r{^[A-Za-z0-9+/]*={0,2}$})
111
+ begin
112
+ Base64.decode64(data)
113
+ rescue StandardError
114
+ data.b
115
+ end
116
+ else
117
+ data.is_a?(String) ? data.b : data
118
+ end
119
+ end
120
+
121
+ def detect_image_format(data)
122
+ # JPEG: starts with FF D8 FF
123
+ if data.bytesize >= 3 && data.getbyte(0) == 0xFF && data.getbyte(1) == 0xD8 && data.getbyte(2) == 0xFF
124
+ width, height = extract_jpeg_dimensions(data)
125
+ return { format: :jpeg, width: width, height: height, filter: "/DCTDecode" } if width && height
126
+ end
127
+
128
+ # PNG: starts with 89 50 4E 47 0D 0A 1A 0A
129
+ if data.bytesize >= 8 && data[0, 8] == "\x89PNG\r\n\x1A\n".b
130
+ width, height = extract_png_dimensions(data)
131
+ # NOTE: filter will be determined in create_image_xobject after PNG is decoded
132
+ return { format: :png, width: width, height: height, filter: "/FlateDecode" } if width && height
133
+ end
134
+
135
+ nil
136
+ end
137
+
138
+ def extract_jpeg_dimensions(data)
139
+ i = 2 # Skip SOI marker
140
+ while i < data.bytesize - 9
141
+ # Look for SOF markers (0xFFC0, 0xFFC1, 0xFFC2, etc.)
142
+ if data.getbyte(i) == 0xFF && (data.getbyte(i + 1) & 0xF0) == 0xC0 && (i + 8 < data.bytesize)
143
+ height = (data.getbyte(i + 5) << 8) | data.getbyte(i + 6)
144
+ width = (data.getbyte(i + 7) << 8) | data.getbyte(i + 8)
145
+ return [width, height] if width.positive? && height.positive?
146
+ end
147
+ i += 1
148
+ end
149
+ # Fallback: if we can't find dimensions, return default 1x1 for minimal JPEGs
150
+ # This handles minimal/truncated JPEGs
151
+ [1, 1]
152
+ end
153
+
154
+ def extract_png_dimensions(data)
155
+ # PNG dimensions are in first IHDR chunk at offset 16
156
+ if data.bytesize >= 24
157
+ width = data[16, 4].unpack1("N")
158
+ height = data[20, 4].unpack1("N")
159
+ return [width, height]
160
+ end
161
+ nil
162
+ end
163
+
164
+ def find_widget_annotation(field_ref)
165
+ # First check patches (for newly created widgets)
166
+ patches.each do |patch|
167
+ next unless patch[:body]
168
+ next unless DictScan.is_widget?(patch[:body])
169
+
170
+ # Check if widget has /Parent pointing to field_ref - use regex directly
171
+ if patch[:body] =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R}
172
+ parent_ref = [Integer(Regexp.last_match(1)), Integer(Regexp.last_match(2))]
173
+ return patch[:ref] if parent_ref == field_ref
174
+ end
175
+
176
+ # Also check if widget IS the field (flat structure) and has /FT /Sig
177
+ if patch[:body].include?("/FT") && DictScan.value_token_after("/FT",
178
+ patch[:body]) == "/Sig" && (patch[:ref] == field_ref)
179
+ return patch[:ref]
180
+ end
181
+ end
182
+
183
+ # Then check resolver (for existing widgets)
184
+ resolver.each_object do |ref, body|
185
+ next unless body && DictScan.is_widget?(body)
186
+
187
+ # Check if widget has /Parent pointing to field_ref - use regex directly
188
+ if body =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R}
189
+ parent_ref = [Integer(Regexp.last_match(1)), Integer(Regexp.last_match(2))]
190
+ return ref if parent_ref == field_ref
191
+ end
192
+
193
+ # Also check if widget IS the field (flat structure) and has /FT /Sig
194
+ if body.include?("/FT") && DictScan.value_token_after("/FT", body) == "/Sig" && (ref == field_ref)
195
+ return ref
196
+ end
197
+ end
198
+
199
+ # Fallback: if field_ref itself is a widget
200
+ body = get_object_body_with_patch(field_ref)
201
+ return field_ref if body && DictScan.is_widget?(body) && body.include?("/FT") && DictScan.value_token_after(
202
+ "/FT", body
203
+ ) == "/Sig"
204
+
205
+ nil
206
+ end
207
+
208
+ def extract_rect(widget_body)
209
+ rect_tok = DictScan.value_token_after("/Rect", widget_body)
210
+ return nil unless rect_tok && rect_tok.start_with?("[")
211
+
212
+ values = rect_tok.scan(/[-+]?\d*\.?\d+/).map(&:to_f)
213
+ return nil unless values.length == 4
214
+
215
+ { x1: values[0], y1: values[1], x2: values[2], y2: values[3] }
216
+ end
217
+
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
+ def create_image_xobject(obj_num, image_data, image_info, _width, _height)
226
+ stream_data = nil
227
+ filter = image_info[:filter]
228
+ mask_obj_num = nil
229
+ mask_body = nil
230
+
231
+ case image_info[:format]
232
+ when :jpeg
233
+ # JPEG can be embedded directly with DCTDecode (no transparency support in JPEG)
234
+ stream_data = image_data.b
235
+ when :png
236
+ # PNG needs to be decoded to raw RGB pixel data
237
+ begin
238
+ png = ChunkyPNG::Image.from_io(StringIO.new(image_data))
239
+
240
+ # Check if image has transparency
241
+ has_transparency = if png.palette
242
+ # Indexed color PNG - check if palette has transparent color
243
+ png.palette.include?(ChunkyPNG::Color::TRANSPARENT)
244
+ else
245
+ # True color PNG - check if any pixel has alpha < 255
246
+ sample_size = [png.width * png.height, 1000].min
247
+ step = [png.width * png.height / sample_size, 1].max
248
+ has_alpha = false
249
+ (0...(png.width * png.height)).step(step) do |i|
250
+ x = i % png.width
251
+ y = i / png.width
252
+ alpha = ChunkyPNG::Color.a(png[x, y])
253
+ if alpha < 255
254
+ has_alpha = true
255
+ break
256
+ end
257
+ end
258
+ has_alpha
259
+ end
260
+
261
+ width = png.width
262
+ height = png.height
263
+
264
+ # Extract RGB pixel data (without compositing)
265
+ rgb_data = +""
266
+ alpha_data = +"" if has_transparency
267
+
268
+ height.times do |y|
269
+ width.times do |x|
270
+ color = png[x, y]
271
+ # Extract RGB components
272
+ r = ChunkyPNG::Color.r(color)
273
+ g = ChunkyPNG::Color.g(color)
274
+ b = ChunkyPNG::Color.b(color)
275
+ rgb_data << [r, g, b].pack("C*")
276
+
277
+ # Extract alpha channel for soft mask
278
+ if has_transparency
279
+ alpha = ChunkyPNG::Color.a(color)
280
+ alpha_data << [alpha].pack("C*")
281
+ end
282
+ end
283
+ end
284
+
285
+ # Compress RGB data with FlateDecode
286
+ stream_data = Zlib::Deflate.deflate(rgb_data)
287
+ filter = "/FlateDecode"
288
+
289
+ # Create soft mask (alpha channel) if transparency is present
290
+ if has_transparency && alpha_data
291
+ mask_obj_num = obj_num + 1
292
+ compressed_alpha = Zlib::Deflate.deflate(alpha_data)
293
+ mask_length = compressed_alpha.bytesize
294
+
295
+ mask_body = "<<\n"
296
+ mask_body += " /Type /XObject\n"
297
+ mask_body += " /Subtype /Image\n"
298
+ mask_body += " /Width #{width}\n"
299
+ mask_body += " /Height #{height}\n"
300
+ mask_body += " /BitsPerComponent 8\n"
301
+ mask_body += " /ColorSpace /DeviceGray\n"
302
+ mask_body += " /Filter /FlateDecode\n"
303
+ mask_body += " /Length #{mask_length}\n"
304
+ mask_body += ">>\n"
305
+ mask_body += "stream\n"
306
+ mask_body += compressed_alpha
307
+ mask_body += "\nendstream"
308
+ end
309
+ rescue StandardError
310
+ # If PNG decoding fails, fall back to trying raw data (won't work but prevents crash)
311
+ stream_data = image_data.b
312
+ end
313
+ else
314
+ stream_data = image_data.b
315
+ end
316
+
317
+ stream_length = stream_data.bytesize
318
+
319
+ dict = "<<\n"
320
+ dict += " /Type /XObject\n"
321
+ dict += " /Subtype /Image\n"
322
+ dict += " /Width #{image_info[:width]}\n"
323
+ dict += " /Height #{image_info[:height]}\n"
324
+ dict += " /BitsPerComponent 8\n"
325
+ dict += " /ColorSpace /DeviceRGB\n"
326
+ dict += " /Filter #{filter}\n"
327
+ dict += " /Length #{stream_length}\n"
328
+ # Add soft mask if transparency is present
329
+ dict += " /SMask #{mask_obj_num} 0 R\n" if mask_obj_num
330
+ dict += ">>\n"
331
+ dict += "stream\n"
332
+ dict += stream_data
333
+ dict += "\nendstream"
334
+
335
+ { body: dict, mask_obj_num: mask_obj_num, mask_body: mask_body }
336
+ end
337
+
338
+ def create_form_xobject(_obj_num, image_obj_num, field_width, field_height, _scale_factor, scaled_width,
339
+ 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
353
+ content_stream = "q\n"
354
+ # First translate to center position
355
+ 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
+ content_stream += "#{scaled_width} 0 0 #{scaled_height} 0 0 cm\n"
358
+ content_stream += "/Im1 Do\n"
359
+ content_stream += "Q"
360
+
361
+ dict = "<<\n"
362
+ dict += " /Type /XObject\n"
363
+ dict += " /Subtype /Form\n"
364
+ dict += " /BBox [0 0 #{field_width} #{field_height}]\n"
365
+ dict += " /Resources << /XObject << /Im1 #{image_obj_num} 0 R >> >>\n"
366
+ dict += " /Length #{content_stream.bytesize}\n"
367
+ dict += ">>\n"
368
+ dict += "stream\n"
369
+ dict += content_stream
370
+ dict += "\nendstream"
371
+
372
+ dict
373
+ end
374
+
375
+ 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
+ new_ap_value = "<< /N #{form_obj_num} 0 R >>"
379
+
380
+ if widget_body.include?("/AP")
381
+ # Extract the full /AP dictionary value (not just the opening <<)
382
+ # Find /AP key
383
+ ap_key_match = widget_body.match(%r{/AP(?=[\s(<\[/])})
384
+ return widget_body unless ap_key_match
385
+
386
+ # Find the start of the value (after /AP and whitespace)
387
+ value_start = ap_key_match.end(0)
388
+ value_start += 1 while value_start < widget_body.length && widget_body[value_start] =~ /\s/
389
+
390
+ # Extract the complete nested dictionary value
391
+ # Start with << and track depth to find matching >>
392
+ depth = 0
393
+ value_end = value_start
394
+ while value_end < widget_body.length
395
+ if widget_body[value_end, 2] == "<<"
396
+ depth += 1
397
+ value_end += 2
398
+ elsif widget_body[value_end, 2] == ">>"
399
+ depth -= 1
400
+ value_end += 2
401
+ break if depth.zero?
402
+ else
403
+ value_end += 1
404
+ end
405
+ end
406
+
407
+ # Replace the complete /AP value
408
+ before = widget_body[0...value_start]
409
+ after = widget_body[value_end..]
410
+ return "#{before}#{new_ap_value}#{after}"
411
+ end
412
+
413
+ # Insert /AP before closing >>
414
+ if widget_body.include?(">>")
415
+ widget_body.sub(/(\s*)>>\s*$/, "\\1#{ap_entry}\n\\1>>")
416
+ else
417
+ widget_body + " #{ap_entry}"
418
+ end
419
+ end
420
+ end
421
+ end
422
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcroThat
4
+ module Actions
5
+ module Base
6
+ def resolver
7
+ @document.instance_variable_get(:@resolver)
8
+ end
9
+
10
+ def patches
11
+ @document.instance_variable_get(:@patches)
12
+ end
13
+
14
+ def get_object_body_with_patch(ref)
15
+ body = resolver.object_body(ref)
16
+ existing_patch = patches.find { |p| p[:ref] == ref }
17
+ existing_patch ? existing_patch[:body] : body
18
+ end
19
+
20
+ def apply_patch(ref, body, original_body = nil)
21
+ original_body ||= resolver.object_body(ref)
22
+ return if body == original_body
23
+
24
+ patches.reject! { |p| p[:ref] == ref }
25
+ patches << { ref: ref, body: body }
26
+ end
27
+
28
+ def next_fresh_object_number
29
+ max_obj_num = 0
30
+ resolver.each_object do |ref, _|
31
+ max_obj_num = [max_obj_num, ref[0]].max
32
+ end
33
+ patches.each do |p|
34
+ max_obj_num = [max_obj_num, p[:ref][0]].max
35
+ end
36
+ max_obj_num + 1
37
+ end
38
+
39
+ def acroform_ref
40
+ @document.send(:acroform_ref)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcroThat
4
+ module Actions
5
+ # Action to remove a field from a PDF document
6
+ class RemoveField
7
+ include Base
8
+
9
+ def initialize(document, field)
10
+ @document = document
11
+ @field = field
12
+ end
13
+
14
+ def call
15
+ af_ref = acroform_ref
16
+ return false unless af_ref
17
+
18
+ # Step 1: Remove widget annotations from pages' /Annots arrays
19
+ remove_widget_annotations_from_pages
20
+
21
+ # Step 2: Remove from /Fields array
22
+ remove_from_fields_array(af_ref)
23
+
24
+ # Step 3: Mark the field object as deleted by setting /T to empty
25
+ mark_field_deleted
26
+
27
+ true
28
+ end
29
+
30
+ private
31
+
32
+ def remove_from_fields_array(af_ref)
33
+ af_body = get_object_body_with_patch(af_ref)
34
+ fields_array_ref = DictScan.value_token_after("/Fields", af_body)
35
+
36
+ if fields_array_ref && fields_array_ref =~ /\A(\d+)\s+(\d+)\s+R/
37
+ arr_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
38
+ arr_body = get_object_body_with_patch(arr_ref)
39
+ filtered = DictScan.remove_ref_from_array(arr_body, @field.ref)
40
+ apply_patch(arr_ref, filtered, arr_body)
41
+ else
42
+ filtered_af = DictScan.remove_ref_from_inline_array(af_body, "/Fields", @field.ref)
43
+ apply_patch(af_ref, filtered_af, af_body) if filtered_af
44
+ end
45
+ end
46
+
47
+ def mark_field_deleted
48
+ fld_body = get_object_body_with_patch(@field.ref)
49
+ return unless fld_body
50
+
51
+ deleted_body = DictScan.replace_key_value(fld_body, "/T", "()")
52
+ apply_patch(@field.ref, deleted_body, fld_body)
53
+ end
54
+
55
+ def remove_widget_annotations_from_pages
56
+ widget_refs_to_remove = []
57
+
58
+ field_body = get_object_body_with_patch(@field.ref)
59
+ if field_body && DictScan.is_widget?(field_body)
60
+ widget_refs_to_remove << @field.ref
61
+ end
62
+
63
+ resolver.each_object do |widget_ref, body|
64
+ next unless body
65
+ next if widget_ref == @field.ref
66
+ next unless DictScan.is_widget?(body)
67
+
68
+ body = get_object_body_with_patch(widget_ref)
69
+
70
+ # Match by /Parent reference
71
+ if body =~ %r{/Parent\s+(\d+)\s+(\d+)\s+R}
72
+ widget_parent_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
73
+ if widget_parent_ref == @field.ref
74
+ widget_refs_to_remove << widget_ref
75
+ next
76
+ end
77
+ end
78
+
79
+ # Also match by field name (/T) - some widgets might not have /Parent
80
+ next unless body.include?("/T") && @field.name
81
+
82
+ t_tok = DictScan.value_token_after("/T", body)
83
+ next unless t_tok
84
+
85
+ widget_name = DictScan.decode_pdf_string(t_tok)
86
+ if widget_name && widget_name == @field.name
87
+ widget_refs_to_remove << widget_ref
88
+ end
89
+ end
90
+
91
+ return if widget_refs_to_remove.empty?
92
+
93
+ widget_refs_to_remove.each do |widget_ref|
94
+ widget_body = get_object_body_with_patch(widget_ref)
95
+
96
+ if widget_body && widget_body =~ %r{/P\s+(\d+)\s+(\d+)\s+R}
97
+ page_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
98
+ remove_widget_from_page_annots(page_ref, widget_ref)
99
+ else
100
+ find_and_remove_widget_from_all_pages(widget_ref)
101
+ end
102
+ end
103
+ end
104
+
105
+ def find_and_remove_widget_from_all_pages(widget_ref)
106
+ # Find all page objects and check their /Annots arrays
107
+ page_objects = []
108
+ resolver.each_object do |ref, body|
109
+ next unless body
110
+
111
+ is_page = body.include?("/Type /Page") ||
112
+ body.include?("/Type/Page") ||
113
+ (body.include?("/Type") && body.include?("/Page") && body =~ %r{/Type\s*/Page})
114
+ next unless is_page
115
+
116
+ page_objects << ref
117
+ end
118
+
119
+ # Check each page's /Annots array
120
+ page_objects.each do |page_ref|
121
+ remove_widget_from_page_annots(page_ref, widget_ref)
122
+ end
123
+ end
124
+
125
+ def remove_widget_from_page_annots(page_ref, widget_ref)
126
+ page_body = get_object_body_with_patch(page_ref)
127
+ return unless page_body
128
+
129
+ # Handle inline /Annots array
130
+ if page_body =~ %r{/Annots\s*\[(.*?)\]}m
131
+ annots_array_str = ::Regexp.last_match(1)
132
+ # Remove the widget reference from the array
133
+ filtered_array = annots_array_str.gsub(/\b#{widget_ref[0]}\s+#{widget_ref[1]}\s+R\b/, "").strip
134
+ # Clean up extra spaces
135
+ filtered_array.gsub!(/\s+/, " ")
136
+
137
+ new_annots = if filtered_array.empty?
138
+ "[]"
139
+ else
140
+ "[#{filtered_array}]"
141
+ end
142
+
143
+ new_page_body = page_body.sub(%r{/Annots\s*\[.*?\]}, "/Annots #{new_annots}")
144
+ apply_patch(page_ref, new_page_body, page_body)
145
+ # Handle indirect /Annots array reference
146
+ elsif page_body =~ %r{/Annots\s+(\d+)\s+(\d+)\s+R}
147
+ annots_array_ref = [Integer(::Regexp.last_match(1)), Integer(::Regexp.last_match(2))]
148
+ annots_array_body = get_object_body_with_patch(annots_array_ref)
149
+
150
+ if annots_array_body
151
+ filtered_body = DictScan.remove_ref_from_array(annots_array_body, widget_ref)
152
+ apply_patch(annots_array_ref, filtered_body, annots_array_body)
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end