Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve error handling and test coverage. #47

Merged
merged 1 commit into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ group :development do
end

group :test do
gem 'logger'
gem 'minitest'
gem 'mutex_m'
end
3 changes: 2 additions & 1 deletion fillable-pdf.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
spec.require_paths = %w[ext lib]

spec.add_runtime_dependency 'rjb', '~> 1.6.0'
spec.add_dependency 'base64', '~> 0.2.0'
spec.add_dependency 'rjb', '~> 1.6.0'
spec.requirements << 'JDK 8.x - 11.x'

spec.metadata = {
Expand Down
124 changes: 86 additions & 38 deletions lib/fillable-pdf.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def initialize(file_path)
@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, read-only, write-protected, encrypted, or may not have any form fields)" # rubocop:disable Layout/LineLength
handle_pdf_open_error(e)
end
end

Expand Down Expand Up @@ -67,7 +67,7 @@ def field(key)
# @return the type of the field
#
def field_type(key)
pdf_field(key).getFormType.toString
pdf_field(key).getFormType&.toString
end

##
Expand All @@ -93,11 +93,16 @@ def fields
# @param [NilClass|TrueClass|FalseClass] 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
#
def set_field(key, value, generate_appearance: nil)
validate_input(key, value)
field = pdf_field(key)

if generate_appearance.nil?
pdf_field(key).setValue(value.to_s)
field.setValue(value.to_s)
else
pdf_field(key).setValue(value.to_s, generate_appearance)
field.setValue(value.to_s, generate_appearance)
end
rescue StandardError => e
raise "Unable to set field '#{key}': #{e.message}"
end

##
Expand All @@ -110,35 +115,39 @@ def set_field(key, value, generate_appearance: nil)
# @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
# Check if the file exists; raise IOError if it doesn't
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
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_width = field.getBorderWidth
bounding_rectangle = ITEXT::Rectangle.new(
orig_rect.getWidth - (border_width * 2),
orig_rect.getHeight - (border_width * 2)
)

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)"
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 "Failed to set image for field '#{key}' (#{e.message})"
end
end

##
Expand All @@ -152,10 +161,16 @@ def set_image(key, file_path) # rubocop:disable Metrics/AbcSize, Metrics/MethodL
#
def set_image_base64(key, base64_image_data)
tmp_file = "#{Dir.tmpdir}/#{SecureRandom.uuid}"
File.binwrite(tmp_file, Base64.decode64(base64_image_data))
set_image(key, tmp_file)
ensure
FileUtils.rm tmp_file
begin
# Use strict_decode64 to ensure invalid Base64 data raises an error
decoded_data = Base64.strict_decode64(base64_image_data)
File.binwrite(tmp_file, decoded_data)
set_image(key, tmp_file)
rescue ArgumentError => e
raise ArgumentError, "Invalid base64 data: #{e.message}"
ensure
FileUtils.rm_f(tmp_file)
end
end

##
Expand All @@ -165,7 +180,7 @@ def set_image_base64(key, base64_image_data)
# @param [NilClass|TrueClass|FalseClass] 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
#
def set_fields(fields, generate_appearance: nil)
fields.each { |key, value| set_field key, value, generate_appearance: generate_appearance }
fields.each { |key, value| set_field(key, value, generate_appearance: generate_appearance) }
end

##
Expand All @@ -175,7 +190,19 @@ def set_fields(fields, generate_appearance: nil)
# @param [String|Symbol] new_key the field name
#
def rename_field(old_key, new_key)
pdf_field(old_key).setFieldName(new_key.to_s)
old_key = old_key.to_s
new_key = new_key.to_s

raise "Field '#{old_key}' not found" unless @form_fields.containsKey(old_key)
raise "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)
rescue StandardError => e
raise "Unable to rename field '#{old_key}' to '#{new_key}': #{e.message}"
end

##
Expand All @@ -184,7 +211,11 @@ def rename_field(old_key, new_key)
# @param [String|Symbol] key the field name
#
def remove_field(key)
@pdf_form.removeField(key.to_s)
if @form_fields.containsKey(key.to_s)
@pdf_form.removeField(key.to_s)
else
raise "Unknown key name `#{key}'"
end
end

##
Expand Down Expand Up @@ -234,6 +265,8 @@ def save_as(file_path, flatten: false)
else
File.open(file_path, 'wb') { |f| f.write(finalize(flatten: flatten)) && f.close }
end
rescue StandardError
raise "Failed to save file '#{file_path}'"
end

##
Expand All @@ -257,11 +290,26 @@ def finalize(flatten: false)
@pdf_form.flattenFields if flatten
close
@byte_stream.toByteArray
rescue StandardError
raise 'Failed to finalize document'
end

def pdf_field(key)
field = @form_fields.get(key.to_s)
raise "unknown key name `#{key}'" if field.nil?
raise "Unknown key name `#{key}'" if field.nil?
field
end

def validate_input(key, value)
raise ArgumentError, 'Field name must be a string or symbol' unless key.is_a?(String) || key.is_a?(Symbol)
raise ArgumentError, 'Field value cannot be nil' if value.nil?
end

def handle_pdf_open_error(err)
if err.message.include?('crypto/BlockCipher')
raise 'The PDF file is encrypted and cannot be opened.'
else
raise "#{err.message} (Input file may be corrupt, incompatible, read-only, write-protected, encrypted, or may not have any form fields)" # rubocop:disable Layout/LineLength
end
end
end
Binary file added test/files/encrypted.pdf
Binary file not shown.
Binary file added test/files/signed-and-certified.pdf
Binary file not shown.
131 changes: 126 additions & 5 deletions test/pdf_test.rb
Original file line number Diff line number Diff line change
@@ -1,16 +1,49 @@
require 'test_helper'
require 'tempfile'

class PdfTest < Minitest::Test
class PdfTest < Minitest::Test # rubocop:disable Metrics/ClassLength
def setup
@pdf = FillablePDF.new 'test/files/filled-out.pdf'
@tmp = 'test/files/tmp.pdf'
# Path to the original PDF
@original_pdf = 'test/files/filled-out.pdf'

# Create a temporary copy of the original PDF for testing
@temp_pdf = Tempfile.new(['filled-out', '.pdf'], 'test/files')
FileUtils.cp(@original_pdf, @temp_pdf.path)

# Initialize FillablePDF with the temporary PDF
@pdf = FillablePDF.new(@temp_pdf.path)

# Temporary file path for saving modifications
@tmp = Tempfile.new(['tmp', '.pdf'], 'test/files').path

# Base64 encoded image data
@base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
end

def teardown
# Close the FillablePDF instance
@pdf&.close

# Delete the temporary PDF files
@temp_pdf.close!
FileUtils.rm_f(@tmp)
end

def test_that_it_has_a_version_number
refute_nil FillablePDF::VERSION
end

def test_set_image_base64_with_tempfile
Tempfile.create(['image', '.png']) do |_temp|
# Use strict_encode64 to avoid newline characters
image_data = Base64.strict_encode64(File.read('test/files/signature.png'))
@pdf.set_image_base64(:signature, image_data)
# Add assertions related to the image being set
# Example:
assert @pdf.set_image_base64(:signature, image_data)
end
end

def test_that_a_file_is_loaded
refute_nil @pdf
end
Expand Down Expand Up @@ -98,7 +131,7 @@ def test_that_a_field_can_be_renamed
@pdf.field(:last_name)
end

assert_match 'unknown key name', err.message
assert_match 'Unknown key name', err.message
assert_equal 'Test', @pdf.field(:surname)
end

Expand All @@ -108,7 +141,7 @@ def test_that_a_field_can_be_removed
@pdf.field(:first_name)
end

assert_match 'unknown key name', err.message
assert_match 'Unknown key name', err.message
end

def test_that_field_names_can_be_accessed
Expand All @@ -132,4 +165,92 @@ def test_that_a_file_can_be_saved
def test_that_a_file_can_be_closed
assert @pdf.close
end

def test_set_field_with_invalid_key
err = assert_raises RuntimeError do
@pdf.set_field(:invalid_key, 'Value')
end
assert_match 'Unknown key name', err.message
end

def test_field_with_invalid_key
err = assert_raises RuntimeError do
@pdf.field(:invalid_key)
end
assert_match 'Unknown key name', err.message
end

def test_field_type_with_invalid_key
err = assert_raises RuntimeError do
@pdf.field_type(:invalid_key)
end
assert_match 'Unknown key name', err.message
end

def test_set_image_with_invalid_path
err = assert_raises IOError do
@pdf.set_image(:signature, 'nonexistent.png')
end
assert_match 'is not found', err.message
end

def test_set_image_base64_with_invalid_data
invalid_base64 = 'invalid_base64_data'
err = assert_raises ArgumentError do
@pdf.set_image_base64(:photo, invalid_base64)
end
assert_match 'Invalid base64', err.message # Match exact casing
end

def test_save_as_with_same_path
assert_silent do
@pdf.save_as(@pdf.instance_variable_get(:@file_path))
end
end

def test_save_with_flattening
@pdf.set_field(:first_name, 'Flattened Name')
@pdf.save_as(@tmp, flatten: true)
reloaded_pdf = FillablePDF.new(@tmp)
assert_raises(RuntimeError) { reloaded_pdf.field(:first_name) }
assert_equal 0, reloaded_pdf.num_fields
ensure
reloaded_pdf&.close
end

def test_open_encrypted_pdf
encrypted_pdf_path = 'test/files/encrypted.pdf'
# Assuming you have an encrypted PDF for testing
err = assert_raises StandardError do
FillablePDF.new(encrypted_pdf_path)
end
assert_equal 'The PDF file is encrypted and cannot be opened.', err.message
end

def test_open_signed_and_certified_pdf
encrypted_pdf_path = 'test/files/signed-and-certified.pdf'
# Assuming you have an encrypted PDF for testing
pdf = FillablePDF.new(encrypted_pdf_path)

assert_predicate pdf, :any_fields?
pdf.close
end

def test_rename_field_to_existing_name
# First, rename :last_name to :surname to create the :surname field
@pdf.rename_field(:last_name, :surname)

# Now, attempt to rename :first_name to :surname, which should raise an error
err = assert_raises RuntimeError do
@pdf.rename_field(:first_name, :surname)
end
assert_match "Field name 'surname' already exists", err.message
end

def test_remove_nonexistent_field
err = assert_raises RuntimeError do
@pdf.remove_field(:nonexistent_field)
end
assert_match 'Unknown key name', err.message # Use exact case
end
end
Loading