Class: FillablePDF

Inherits:
Object
  • Object
show all
Includes:
SuppressWarnings
Defined in:
lib/fillable-pdf.rb,
lib/fillable-pdf/field.rb,
lib/fillable-pdf/itext.rb,
lib/fillable-pdf/errors.rb,
lib/fillable-pdf/version.rb,
lib/fillable-pdf/suppress_warnings.rb

Overview

rubocop:disable Metrics/ClassLength

Defined Under Namespace

Modules: ITEXT, SuppressWarnings Classes: Error, Field, FieldNotFoundError, FileOperationError, InvalidArgumentError

Constant Summary collapse

VERSION =
'1.0.0'

Instance Method Summary collapse

Methods included from SuppressWarnings

#suppress_warnings

Constructor Details

#initialize(file_path) ⇒ FillablePDF

Opens a given fillable-pdf PDF file and prepares it for modification.

@param [String|Symbol] file_path the name of the PDF file or file path
@raise [FileOperationError] if the file is not found or cannot be opened

Raises:



18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'lib/fillable-pdf.rb', line 18

def initialize(file_path) # rubocop:disable Metrics/MethodLength
  raise FileOperationError, "File <#{file_path}> is not found" unless File.exist?(file_path)
  @file_path = file_path
  @closed = false
  begin
    @byte_stream = ITEXT::ByteArrayOutputStream.new
    @pdf_reader = ITEXT::PdfReader.new @file_path.to_s
    @pdf_writer = ITEXT::PdfWriter.new @byte_stream
    @pdf_doc = ITEXT::PdfDocument.new @pdf_reader, @pdf_writer
    @pdf_form = ITEXT::PdfAcroForm.getAcroForm(@pdf_doc, true)
    @form_fields = @pdf_form.getAllFormFields
  rescue StandardError => e
    handle_pdf_open_error(e)
  end
end

Instance Method Details

#any_fields?Boolean

Determines whether the form has any fields.

@return [Boolean] true if form has fields, false otherwise

Returns:

  • (Boolean)


39
40
41
# File 'lib/fillable-pdf.rb', line 39

def any_fields?
  field_count.positive?
end

#closeBoolean

Closes the PDF document discarding all unsaved changes.

Returns:

  • (Boolean)

    true if document is closed



357
358
359
360
361
362
363
# File 'lib/fillable-pdf.rb', line 357

def close # rubocop:disable Naming/PredicateMethod
  return true if closed?

  @pdf_doc.close
  @closed = true
  true
end

#closed?Boolean

Checks if the PDF document is closed.

Returns:

  • (Boolean)

    true if document is closed, false otherwise



370
371
372
# File 'lib/fillable-pdf.rb', line 370

def closed?
  @closed ||= false
end

#field(key) ⇒ Object

Retrieves the value of a field given its unique field name.

@param [String|Symbol] key the field name
@return [String] the value of the field
@raise [FieldNotFoundError] if the field does not exist


66
67
68
69
70
# File 'lib/fillable-pdf.rb', line 66

def field(key)
  pdf_field(key).getValueAsString
rescue NoMethodError
  raise FieldNotFoundError, "Unknown key name `#{key}'"
end

#field_countObject

Returns the total number of fillable form fields.

@return [Integer] the number of fields


48
49
50
# File 'lib/fillable-pdf.rb', line 48

def field_count
  @form_fields.size
end

#field_type(key) ⇒ Object

Retrieves the string type of a field given its unique field name.

@param [String|Symbol] key the field name
@return [String, nil] the type of the field (e.g., '/Btn', '/Tx', '/Ch', '/Sig')
@raise [FieldNotFoundError] if the field does not exist


79
80
81
# File 'lib/fillable-pdf.rb', line 79

def field_type(key)
  pdf_field(key).getFormType&.toString
end

#fieldsObject

Retrieves a hash of all fields and their values.

@return [Hash{Symbol => String}] hash of field keys (as symbols) and values


88
89
90
91
92
93
94
95
96
# File 'lib/fillable-pdf.rb', line 88

def fields
  iterator = @form_fields.keySet.iterator
  map = {}
  while iterator.hasNext
    key = iterator.next.toString
    map[key.to_sym] = field(key)
  end
  map
end

#namesObject

Returns a list of all field keys used in the document.

@return [Array<Symbol>] array of field names as symbols


274
275
276
277
278
279
# File 'lib/fillable-pdf.rb', line 274

def names
  iterator = @form_fields.keySet.iterator
  set = []
  set << iterator.next.toString.to_sym while iterator.hasNext
  set
end

#num_fieldsObject

Deprecated.

Use #field_count instead



54
55
56
57
# File 'lib/fillable-pdf.rb', line 54

def num_fields
  warn '[DEPRECATION] `num_fields` is deprecated. Use `field_count` instead.'
  field_count
end

#remove_field(key) ⇒ Object

Removes a field from the document given its unique field name.

@param [String|Symbol] key the field name
@return [self] returns self for method chaining
@raise [FieldNotFoundError] if the field does not exist

Raises:



258
259
260
261
262
263
264
265
266
267
# File 'lib/fillable-pdf.rb', line 258

def remove_field(key)
  ensure_document_open
  validate_field_name(key)
  raise FieldNotFoundError, "Unknown key name `#{key}'" unless @form_fields.containsKey(key.to_s)

  @pdf_form.removeField(key.to_s)
  @form_fields.remove(key.to_s)

  self
end

#rename_field(old_key, new_key) ⇒ Object

Renames a field given its unique field name and the new field name.

@param [String|Symbol] old_key the current field name
@param [String|Symbol] new_key the new field name
@return [self] returns self for method chaining
@raise [FieldNotFoundError] if the field does not exist
@raise [InvalidArgumentError] if the new field name already exists


227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/fillable-pdf.rb', line 227

def rename_field(old_key, new_key) # rubocop:disable Metrics/MethodLength
  ensure_document_open
  validate_field_name(old_key)
  validate_field_name(new_key)

  old_key = old_key.to_s
  new_key = new_key.to_s

  raise FieldNotFoundError, "Field `#{old_key}` not found" unless @form_fields.containsKey(old_key)
  raise InvalidArgumentError, "Field name `#{new_key}` already exists" if @form_fields.containsKey(new_key)

  field = pdf_field(old_key)
  field.setFieldName(new_key)

  @form_fields.remove(old_key)
  @form_fields.put(new_key, field)

  self
rescue FieldNotFoundError, InvalidArgumentError
  raise
rescue StandardError => e
  raise FileOperationError, "Unable to rename field `#{old_key}` to `#{new_key}`: #{e.message}"
end

#save(flatten: false) ⇒ Object

Overwrites the previously opened PDF document and flattens it if requested.

@param [Boolean] flatten true if PDF should be flattened, false otherwise
@return [self] returns self for method chaining
@raise [FileOperationError] if the save operation fails


300
301
302
303
304
305
306
# File 'lib/fillable-pdf.rb', line 300

def save(flatten: false)
  ensure_document_open
  tmp_file = "#{Dir.tmpdir}/#{SecureRandom.uuid}"
  save_as(tmp_file, flatten: flatten)
  FileUtils.mv tmp_file, @file_path
  self
end

#save_as(file_path, flatten: false) ⇒ Object

Saves the filled out PDF document in a given path and flattens it if requested. If the path matches the current file path, it will call save() instead.

@param [String] file_path the name of the PDF file or file path
@param [Boolean] flatten true if PDF should be flattened, false otherwise
@return [self] returns self for method chaining
@raise [FileOperationError] if the save operation fails


317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/fillable-pdf.rb', line 317

def save_as(file_path, flatten: false)
  ensure_document_open
  if @file_path == file_path
    save(flatten: flatten)
    return self
  end

  File.open(file_path, 'wb') { |f| f.write(finalize(flatten: flatten)) && f.close }
  self
rescue StandardError => e
  raise FileOperationError, "Failed to save file `#{file_path}`: #{e.message}"
end

#save_as!(file_path, flatten: false) ⇒ Object

Saves the filled out PDF document in a given path and flattens it if requested. Raises an error if the path matches the current file path (use save() instead).

@param [String] file_path the name of the PDF file or file path
@param [Boolean] flatten true if PDF should be flattened, false otherwise
@return [self] returns self for method chaining
@raise [InvalidArgumentError] if file_path matches the current file path
@raise [FileOperationError] if the save operation fails


340
341
342
343
344
345
346
347
348
349
350
# File 'lib/fillable-pdf.rb', line 340

def save_as!(file_path, flatten: false)
  ensure_document_open
  raise InvalidArgumentError, 'Cannot save_as! to the same file path. Use save() instead.' if @file_path == file_path

  File.open(file_path, 'wb') { |f| f.write(finalize(flatten: flatten)) && f.close }
  self
rescue InvalidArgumentError
  raise
rescue StandardError => e
  raise FileOperationError, "Failed to save file `#{file_path}`: #{e.message}"
end

#set_field(key, value, generate_appearance: nil) ⇒ Object

Sets the value of a field given its unique field name and value.

@param [String|Symbol] key the field name
@param [String|Symbol] value the field value
@param [Boolean, nil] generate_appearance true to generate appearance, false to let the PDF viewer application generate form field appearance, nil (default) to let iText decide what's appropriate
@return [self] returns self for method chaining
@raise [InvalidArgumentError] if key or value are invalid
@raise [FieldNotFoundError] if the field does not exist


108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/fillable-pdf.rb', line 108

def set_field(key, value, generate_appearance: nil)
  ensure_document_open
  validate_input(key, value)
  field = pdf_field(key)

  if generate_appearance.nil?
    field.setValue(value.to_s)
  else
    field.setValue(value.to_s, generate_appearance)
  end

  self
end

#set_fields(fields, generate_appearance: nil) ⇒ Object

Sets the values of multiple fields given a set of unique field names and values.

@param [Hash{String, Symbol => String}] fields the set of field names and values
@param [Boolean, nil] generate_appearance true to generate appearance, false to let the PDF viewer application generate form field appearance,  nil (default) to let iText decide what's appropriate
@return [self] returns self for method chaining
@raise [InvalidArgumentError] if any key or value is invalid
@raise [FieldNotFoundError] if any field does not exist


212
213
214
215
216
# File 'lib/fillable-pdf.rb', line 212

def set_fields(fields, generate_appearance: nil)
  ensure_document_open
  fields.each { |key, value| set_field(key, value, generate_appearance: generate_appearance) }
  self
end

#set_image(key, file_path) ⇒ Object

Sets an image within the bounds of the given form field. It doesn’t matter what type of form field it is (signature, image, etc). The image will be scaled to fill the available space while preserving its aspect ratio. All previous content will be removed, which means you cannot have both text and image.

@param [String|Symbol] key the field name
@param [String|Symbol] file_path the name of the image file or image path
@return [self] returns self for method chaining
@raise [FileOperationError] if the image file is not found
@raise [FieldNotFoundError] if the field does not exist

Raises:



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/fillable-pdf.rb', line 134

def set_image(key, file_path) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
  ensure_document_open
  raise FileOperationError, "File <#{file_path}> is not found" unless File.exist?(file_path)

  begin
    field = pdf_field(key)
    widgets = field.getWidgets
    widget_dict = suppress_warnings { widgets.isEmpty ? field.getPdfObject : widgets.get(0).getPdfObject }
    orig_rect = widget_dict.getAsRectangle(ITEXT::PdfName.Rect)

    border_style = field.getWidgets.get(0).getBorderStyle
    border_width = border_style.nil? ? 0 : border_style.getWidth

    bounding_rectangle = ITEXT::Rectangle.new(
      orig_rect.getWidth - (border_width * 2),
      orig_rect.getHeight - (border_width * 2)
    )

    pdf_form_x_object = ITEXT::PdfFormXObject.new(bounding_rectangle)
    canvas = ITEXT::Canvas.new(pdf_form_x_object, @pdf_doc)
    image = ITEXT::Image.new(ITEXT::ImageDataFactory.create(file_path.to_s))
                        .setAutoScale(true)
                        .setHorizontalAlignment(ITEXT::HorizontalAlignment.CENTER)
    container = ITEXT::Div.new
                          .setMargin(border_width).add(image)
                          .setVerticalAlignment(ITEXT::VerticalAlignment.MIDDLE)
                          .setFillAvailableArea(true)
    canvas.add(container)
    canvas.close

    pdf_dict = ITEXT::PdfDictionary.new
    widget_dict.put(ITEXT::PdfName.AP, pdf_dict)
    pdf_dict.put(ITEXT::PdfName.N, pdf_form_x_object.getPdfObject)
    widget_dict.setModified
  rescue StandardError => e
    raise FileOperationError, "Failed to set image for field '#{key}': #{e.message}"
  end

  self
end

#set_image_base64(key, base64_image_data) ⇒ Object

Sets an image within the bounds of the given form field. It doesn’t matter what type of form field it is (signature, image, etc). The image will be scaled to fill the available space while preserving its aspect ratio. All previous content will be removed, which means you cannot have both text and image.

@param [String|Symbol] key the field name
@param [String] base64_image_data base64 encoded image data
@return [self] returns self for method chaining
@raise [InvalidArgumentError] if the base64 data is invalid
@raise [FieldNotFoundError] if the field does not exist


187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/fillable-pdf.rb', line 187

def set_image_base64(key, base64_image_data)
  ensure_document_open
  tmp_file = "#{Dir.tmpdir}/#{SecureRandom.uuid}"
  begin
    decoded_data = Base64.strict_decode64(base64_image_data)
    File.binwrite(tmp_file, decoded_data)
    set_image(key, tmp_file)
  rescue ArgumentError => e
    raise InvalidArgumentError, "Invalid base64 data: #{e.message}"
  ensure
    FileUtils.rm_f(tmp_file)
  end

  self
end

#valuesObject

Returns a list of all field values used in the document.

@return [Array<String>] array of field values


286
287
288
289
290
291
# File 'lib/fillable-pdf.rb', line 286

def values
  iterator = @form_fields.keySet.iterator
  set = []
  set << field(iterator.next.toString) while iterator.hasNext
  set
end