Skip to content

Commit

Permalink
Add support for images in signature/image fields.
Browse files Browse the repository at this point in the history
Whether a signature field expects a digital signature or a text signature, an image can now be added into that field from a file or a base64 encoded string. In fact, adding images works for any field, and the image will always overwrite whatever was in the field while automatically resizing to fill the bounds.
  • Loading branch information
vkononov committed Dec 2, 2021
1 parent 24d85ea commit a04cc42
Show file tree
Hide file tree
Showing 15 changed files with 148 additions and 43 deletions.
6 changes: 6 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ AllCops:
NewCops: enable
Exclude:
- .git/**/*
- vendor/bundle/**/*

Gemspec/RequiredRubyVersion:
Enabled: false
Expand All @@ -25,12 +26,17 @@ Layout/InitialIndentation:

Layout/LineLength:
Exclude:
- README.md
- fillable-pdf.gemspec
Max: 120

Layout/SpaceInsideHashLiteralBraces:
Enabled: false

Lint/ConstantDefinitionInBlock:
Exclude:
- lib/fillable-pdf/itext.rb

Metrics/MethodLength:
Max: 12

Expand Down
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ sudo: false
language: ruby
cache: bundler
rvm:
- 3.0.2
- 3.0.3
jdk:
- openjdk8
before_install:
Expand Down
49 changes: 35 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# FillablePDF

[![Gem Version](https://badge.fury.io/rb/fillable-pdf.svg)](https://rubygems.org/gems/fillable-pdf)
[![Build Status](https://api.travis-ci.org/vkononov/fillable-pdf.svg?branch=master)](http://travis-ci.org/vkononov/fillable-pdf)
[![Build Status](https://app.travis-ci.com/vkononov/fillable-pdf.svg?branch=master)](http://travis-ci.org/vkononov/fillable-pdf)

FillablePDF is an extremely simple and lightweight utility that bridges iText and Ruby in order to fill out fillable PDF forms or extract field values from previously filled out PDF forms.

Expand Down Expand Up @@ -154,6 +154,14 @@ To unset the radio button use the `'Off'` string:
pdf.set_field(:language, 'Off')
```
### Adding Signatures or Images
Digital signatures are not supported, but you can place an image or a base64 encoded image within the bounds of any form field.
SVG images are not supported. You will have to convert them to a JPG or PNG first.
See methods `set_image` and `set_image_base64` below.
### Instance Methods
An instance of `FillablePDF` has the following methods at its disposal:
Expand Down Expand Up @@ -220,6 +228,22 @@ An instance of `FillablePDF` has the following methods at its disposal:
# result: changes the values of 'first_name' and 'last_name'
```
* `set_image`
*Places an image file within the rectangular bounding box of the given form field.*
```ruby
pdf.set_image(:signature, 'signature.png')
# result: the image 'signature.png' is shown in the foreground of the form field
```
* `set_image_base64`
*Places a base64 encoded image within the rectangular bounding box of the given form field.*
```ruby
pdf.set_image_base64(:signature, 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==')
# result: the base64 encoded image is shown in the foreground of the form field
```
* `rename_field`
*Renames a field given its unique field name and the new field name.*
Expand Down Expand Up @@ -285,11 +309,13 @@ An instance of `FillablePDF` has the following methods at its disposal:
## Example
The following example [example.rb](example/run.rb) and the input file [input.pdf](example/input.pdf) are located in the `test` directory. It uses all of the methods that are described above and generates the output files [output.pdf](example/output.pdf) and [output.flat.pdf](example/output.flat.pdf).
The following [example.rb](example/run.rb) with [input.pdf](example/input.pdf) is located in the [example](example) directory. It uses all of the methods that are described above and generates the output files [output.pdf](example/output.pdf) and [output.flat.pdf](example/output.flat.pdf).
```ruby
require_relative '../lib/fillable-pdf'
BASE64_PHOTO = 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==' # rubocop:disable Layout/LineLength
# opening a fillable PDF
pdf = FillablePDF.new('input.pdf')
Expand All @@ -304,11 +330,12 @@ puts
# setting form fields
pdf.set_fields(first_name: 'Richard', last_name: 'Rahl')
pdf.set_fields(football: 'Yes', baseball: 'Yes',
basketball: 'Yes', nascar: 'Yes', hockey: 'Yes')
pdf.set_fields(football: 'Yes', baseball: 'Yes', basketball: 'Yes', nascar: 'Yes', hockey: 'Yes')
pdf.set_field(:date, Time.now.strftime('%B %e, %Y'))
pdf.set_field(:newsletter, 'Off') # uncheck the checkbox
pdf.set_field(:language, 'dart') # select a radio button option
pdf.set_image_base64(:photo, BASE64_PHOTO)
pdf.set_image(:signature, 'signature.png')
# list of fields
puts "Fields hash: #{pdf.fields}"
Expand Down Expand Up @@ -343,10 +370,6 @@ puts
# Removing field
pdf.remove_field :nascar
puts "Removed field 'nascar'"
puts
# printing the name of the person used inside the PDF
puts "Signatory: #{pdf.field(:first_name)} #{pdf.field(:last_name)}"
# saving the filled out PDF in another file
pdf.save_as('output.pdf')
Expand All @@ -362,21 +385,19 @@ pdf.close
The example above produces the following output and also generates the output file [output.pdf](example/output.pdf).
```text
The form has a total of 14 fields.
The form has a total of 16 fields.
Fields hash: {:last_name=>"Rahl", :first_name=>"Richard", :football=>"Yes", :baseball=>"Yes", :basketball=>"Yes", :hockey=>"Yes", :date=>"November 15, 2021", :newsletter=>"Off", :nascar=>"Yes", :language=>"dart", :"language.1"=>"dart", :"language.2"=>"dart", :"language.3"=>"dart", :"language.4"=>"dart"}
Fields hash: {:last_name=>"Rahl", :first_name=>"Richard", :football=>"Yes", :baseball=>"Yes", :basketball=>"Yes", :hockey=>"Yes", :date=>"November 16, 2021", :newsletter=>"Off", :nascar=>"Yes", :language=>"dart", :"language.1"=>"dart", :"language.2"=>"dart", :"language.3"=>"dart", :"language.4"=>"dart", :signature=>"", :photo=>""}
Keys: [:last_name, :first_name, :football, :baseball, :basketball, :hockey, :date, :newsletter, :nascar, :language, :"language.1", :"language.2", :"language.3", :"language.4"]
Keys: [:last_name, :first_name, :football, :baseball, :basketball, :hockey, :date, :newsletter, :nascar, :language, :"language.1", :"language.2", :"language.3", :"language.4", :signature, :photo]
Values: ["Rahl", "Richard", "Yes", "Yes", "Yes", "Yes", "November 15, 2021", "Off", "Yes", "dart", "dart", "dart", "dart", "dart"]
Values: ["Rahl", "Richard", "Yes", "Yes", "Yes", "Yes", "November 16, 2021", "Off", "Yes", "dart", "dart", "dart", "dart", "dart", "", ""]
Field 'football' is of type BUTTON
Renamed field 'last_name' to 'surname'
Removed field 'nascar'
Signatory: Richard Rahl
```
## Contributing
Expand Down
Binary file modified example/input.pdf
Binary file not shown.
Binary file modified example/output.flat.pdf
Binary file not shown.
Binary file modified example/output.pdf
Binary file not shown.
11 changes: 5 additions & 6 deletions example/run.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
require_relative '../lib/fillable-pdf'

BASE64_PHOTO = 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==' # rubocop:disable Layout/LineLength

# opening a fillable PDF
pdf = FillablePDF.new('input.pdf')

Expand All @@ -14,11 +16,12 @@

# setting form fields
pdf.set_fields(first_name: 'Richard', last_name: 'Rahl')
pdf.set_fields(football: 'Yes', baseball: 'Yes',
basketball: 'Yes', nascar: 'Yes', hockey: 'Yes')
pdf.set_fields(football: 'Yes', baseball: 'Yes', basketball: 'Yes', nascar: 'Yes', hockey: 'Yes')
pdf.set_field(:date, Time.now.strftime('%B %e, %Y'))
pdf.set_field(:newsletter, 'Off') # uncheck the checkbox
pdf.set_field(:language, 'dart') # select a radio button option
pdf.set_image_base64(:photo, BASE64_PHOTO)
pdf.set_image(:signature, 'signature.png')

# list of fields
puts "Fields hash: #{pdf.fields}"
Expand Down Expand Up @@ -53,10 +56,6 @@
# Removing field
pdf.remove_field :nascar
puts "Removed field 'nascar'"
puts

# printing the name of the person used inside the PDF
puts "Signatory: #{pdf.field(:first_name)} #{pdf.field(:last_name)}"

# saving the filled out PDF in another file
pdf.save_as('output.pdf')
Expand Down
Binary file added example/signature.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 4 additions & 6 deletions lib/field.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
require_relative 'fillable-pdf/itext'
require_relative 'kernel'

class Field
# PdfName has a constant "A" and a constant "a". Unfortunately, RJB does not differentiate
# between these constants and tries to create the same constant ("A") for both, which causes
# an annoying warning "already initialized constant Rjb::Com_itextpdf_kernel_pdf_PdfName::A".
# As long as RJB has not fixed this issue, this warning will remain suppressed.
suppress_warnings { PDF_NAME = Rjb.import('com.itextpdf.kernel.pdf.PdfName') } # rubocop:disable Lint/ConstantDefinitionInBlock

BUTTON = PDF_NAME.Btn.toString
CHOICE = PDF_NAME.Ch.toString
SIGNATURE = PDF_NAME.Sig.toString
TEXT = PDF_NAME.Tx.toString
BUTTON = ITEXT::PdfName.Btn.toString
CHOICE = ITEXT::PdfName.Ch.toString
SIGNATURE = ITEXT::PdfName.Sig.toString
TEXT = ITEXT::PdfName.Tx.toString
end
81 changes: 66 additions & 15 deletions lib/fillable-pdf.rb
Original file line number Diff line number Diff line change
@@ -1,31 +1,24 @@
require_relative 'fillable-pdf/itext'
require_relative 'field'
require 'base64'
require 'fileutils'
require 'securerandom'

class FillablePDF
# required Java imports
BYTE_STREAM = Rjb.import 'com.itextpdf.io.source.ByteArrayOutputStream'
PDF_READER = Rjb.import 'com.itextpdf.kernel.pdf.PdfReader'
PDF_WRITER = Rjb.import 'com.itextpdf.kernel.pdf.PdfWriter'
PDF_DOCUMENT = Rjb.import 'com.itextpdf.kernel.pdf.PdfDocument'
PDF_ACRO_FORM = Rjb.import 'com.itextpdf.forms.PdfAcroForm'
PDF_FORM_FIELD = Rjb.import 'com.itextpdf.forms.fields.PdfFormField'

class FillablePDF # rubocop:disable Metrics/ClassLength
##
# 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
#
def initialize(file_path)
raise IOError, "File at `#{file_path}' is not found" unless File.exist?(file_path)
raise IOError, "File <#{file_path}> is not found" unless File.exist?(file_path)
@file_path = file_path
begin
@byte_stream = BYTE_STREAM.new
@pdf_reader = PDF_READER.new @file_path.to_s
@pdf_writer = PDF_WRITER.new @byte_stream
@pdf_doc = PDF_DOCUMENT.new @pdf_reader, @pdf_writer
@pdf_form = PDF_ACRO_FORM.getAcroForm(@pdf_doc, true)
@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.getFormFields
rescue StandardError => e
raise "#{e.message} (input file may be corrupt, incompatible, or may not have any forms)"
Expand Down Expand Up @@ -99,6 +92,64 @@ def set_field(key, value)
pdf_field(key).setValue(value.to_s)
end

##
# 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
#
def set_image(key, file_path) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
raise IOError, "File <#{file_path}> is not found" unless File.exist?(file_path)
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_width = field.getBorderWidth
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 "#{e.message} (there may be something wrong with your image)"
end

##
# 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] base64_image_data base64 encoded data image
#
def set_image_base64(key, base64_image_data)
tmp_file = SecureRandom.uuid
File.open(tmp_file, 'wb') { |f| f.write(Base64.decode64(base64_image_data)) }
set_image(key, tmp_file)
ensure
FileUtils.rm tmp_file
end

##
# Sets the values of multiple fields given a set of unique field names and values.
#
Expand Down
21 changes: 21 additions & 0 deletions lib/fillable-pdf/itext.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
require_relative 'kernel'
require 'rjb'

Rjb.load(Dir.glob(File.expand_path('../../ext/*.jar', __dir__)).join(':'))

module ITEXT
suppress_warnings do
ByteArrayOutputStream = Rjb.import 'com.itextpdf.io.source.ByteArrayOutputStream'
Canvas = Rjb.import 'com.itextpdf.layout.Canvas'
Div = Rjb.import 'com.itextpdf.layout.element.Div'
HorizontalAlignment = Rjb.import 'com.itextpdf.layout.property.HorizontalAlignment'
Image = Rjb.import 'com.itextpdf.layout.element.Image'
ImageDataFactory = Rjb.import 'com.itextpdf.io.image.ImageDataFactory'
PdfAcroForm = Rjb.import 'com.itextpdf.forms.PdfAcroForm'
PdfDictionary = Rjb.import 'com.itextpdf.kernel.pdf.PdfDictionary'
PdfDocument = Rjb.import 'com.itextpdf.kernel.pdf.PdfDocument'
PdfFormXObject = Rjb.import 'com.itextpdf.kernel.pdf.xobject.PdfFormXObject'
PdfName = Rjb.import 'com.itextpdf.kernel.pdf.PdfName'
PdfReader = Rjb.import 'com.itextpdf.kernel.pdf.PdfReader'
PdfWriter = Rjb.import 'com.itextpdf.kernel.pdf.PdfWriter'
Rectangle = Rjb.import 'com.itextpdf.kernel.geom.Rectangle'
VerticalAlignment = Rjb.import 'com.itextpdf.layout.property.VerticalAlignment'
end
end
File renamed without changes.
Binary file modified test/files/filled-out.pdf
Binary file not shown.
Binary file added test/files/signature.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 10 additions & 1 deletion test/pdf_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ class PdfTest < Minitest::Test
def setup
@pdf = FillablePDF.new 'test/files/filled-out.pdf'
@tmp = 'test/files/tmp.pdf'
@base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
end

def test_that_it_has_a_version_number
Expand All @@ -30,7 +31,7 @@ def test_that_file_has_a_positive_number_of_editable_fields
end

def test_that_hash_can_be_accessed
assert_equal 14, @pdf.fields.length
assert_equal 16, @pdf.fields.length
end

def test_that_a_field_value_can_be_accessed_by_name
Expand All @@ -48,6 +49,14 @@ def test_that_a_field_value_can_be_modified
assert_equal 'Richard', @pdf.field(:first_name)
end

def test_that_an_image_can_be_placed_in_signature_field
assert @pdf.set_image(:signature, 'test/files/signature.png')
end

def test_that_a_base64_can_be_placed_in_photo_field
assert @pdf.set_image_base64(:photo, @base64)
end

def test_that_an_asian_font_works
@pdf.set_field(:first_name, '理查德')
assert_equal '理查德', @pdf.field(:first_name)
Expand Down

0 comments on commit a04cc42

Please sign in to comment.