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.
- checksums.yaml +7 -0
- data/.DS_Store +0 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +78 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +86 -0
- data/README.md +360 -0
- data/Rakefile +18 -0
- data/acro_that.gemspec +34 -0
- data/docs/README.md +99 -0
- data/docs/dict_scan_explained.md +341 -0
- data/docs/object_streams.md +311 -0
- data/docs/pdf_structure.md +251 -0
- data/lib/acro_that/actions/add_field.rb +278 -0
- data/lib/acro_that/actions/add_signature_appearance.rb +422 -0
- data/lib/acro_that/actions/base.rb +44 -0
- data/lib/acro_that/actions/remove_field.rb +158 -0
- data/lib/acro_that/actions/update_field.rb +301 -0
- data/lib/acro_that/dict_scan.rb +413 -0
- data/lib/acro_that/document.rb +331 -0
- data/lib/acro_that/field.rb +143 -0
- data/lib/acro_that/incremental_writer.rb +244 -0
- data/lib/acro_that/object_resolver.rb +376 -0
- data/lib/acro_that/objstm.rb +75 -0
- data/lib/acro_that/pdf_writer.rb +97 -0
- data/lib/acro_that/version.rb +5 -0
- data/lib/acro_that.rb +24 -0
- metadata +143 -0
|
@@ -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
|