Generating PDF Reports within Rails using Prawn PDF and Prawn Table Gems.
When I’m working on different projects, I often have to create PDFs for important documents like transaction summaries and invoices. But don’t worry, it’s not as complicated as it sounds! In this blog post, I’ll show you a simple way to make PDFs. If you’re looking for PDF gems for Ruby, just search for “PDF” on RubyGems.org or LibHunt and you’ll find plenty of options.
Let's dive right in then. We will start by creating an empty new project
rails new prawn_pdf
After the project files have been generated, we will need to add the gems required to the Gemfile and run bundle install
.
gem "prawn", "~> 2.4" # for generating pdfs
gem "prawn-table", "~> 0.2.2" # for adding tables to prawn pdfs, -
# because most reports have tables in them.
gem "prawn-icon", "~> 3.1" # You may need this if you want to use icons at all
gem "letter_opener", group: :development # add this under the development section.
You can find the links to the Gem GitHub pages at the end of this blog. Prawn Gem has a PDF doc and does Prawn Table Gem.
There are multiple ways to approach building a PDF, but I usually approach it like I am designing an email. I use tables a lot to align stuff really well across the page.
For this blog post, I found this random PDF online, and I will try to redesign it.
Since I will be using the PDF throughout my entire Rails application, I will place it in a service. Let’s create one under `app/services/pdf_generator_service.rb`
The Initialization:
In this section, I set up everything that would need to be used throughout the pdf sections like the: fonts
, page orientation
and other components
class PdfGeneratorService
MONTSERRAT_FONT_PATH = 'app/assets/stylesheets/Montserrat-Medium.ttf'
MONTSERRAT_BOLD_FONT_PATH = 'app/assets/stylesheets/Montserrat-Black.ttf'
LOGO_IMG_PATH = 'app/assets/images/logo.png'
def initialize
@pdf = Prawn::Document.new
@document_width = @pdf.bounds.width # width fo the document
@pdf.font_families.update(
'montserrat' => {
normal: MONTSERRAT_FONT_PATH, # Path to the normal (regular) font style
bold: MONTSERRAT_BOLD_FONT_PATH # Path to the bold font style (if applicable)
}
)
end
end
In this case, I wanted to use a custom font called montserrat
for which I downloaded .ttf
a file from Google Fonts to use in this pdf. Prawn supports custom fonts as well.
The Header Section:
class PdfGeneratorService
...
def header
status_results = [%w[15 3 5 4 3], %w[TESTS FAILED PASSED BLOCKED UNKNOWN]]
text_colors = { 1 => 'C8102F', 2 => '83BC41', 3 => 'FDB357', 4 => 'BBBBBB' }
header_table = @pdf.make_table(status_results) do |table|
table.row(0).font_style = :bold
table.row(0).font = 'montserrat'
header_status_rows = [0, 1]
header_status_rows.each do |row_index|
text_colors.each do |column_index, color|
table.row(row_index).column(column_index).style(text_color: color)
end
end
table.row(0..1).border_width = 0
table.row(0..1).column(0).border_width = 1
table.row(0..1).column(0).border_color = 'c0c5ce'
table.row(0..1).column(0).borders = [:right]
table.row(0).size = 17
table.row(1).size = 11
end
header_column_widths = [@document_width * 2 / 3, @document_width * 1 / 3]
header_title_data = [['Execution Summary']]
header_title_options = {
column_widths: [@document_width],
row_colors: ['EDEFF5'],
cell_style: {
border_width: 0,
padding: [15, 12, 1, 20],
size: 20,
font: 'montserrat',
font_style: :normal
}
}
header_logo_data = [[header_table, { image: LOGO_IMG_PATH, position: :center, scale: 0.5, colspan: 6 }]]
header_logo_options = {
column_widths: header_column_widths,
row_colors: ['EDEFF5'],
cell_style: {
border_width: 2,
padding: [15, 15],
borders: [:bottom],
border_color: 'c9ced5'
}
}
@pdf.table(header_title_data, header_title_options)
@pdf.table(header_logo_data, header_logo_options)
end
...
end
So this gives us this and I know it is not the exact replica but due to limited time, this is how close I got;
Key takeaways;
- Prawn allows tables can be nested into other tables
The Middle Section:
class PdfGeneratorService
...
def mid_section
mid_section_data = [['General', '', ''], ['Start time:', 'Duration:', 'Executed by:'],
["Apr 7, 2020 \n 10:33:57", '15m 9s', 'z-automation-sayvr@perfectmobile.com']]
mid_section_options = {
width: @document_width,
row_colors: ['ffffff'],
cell_style: {
border_width: 0,
borders: [:bottom],
border_color: 'c9ced5',
padding: [10, 15]
}
}
@pdf.table(mid_section_data, mid_section_options) do |table|
table.row(0).border_width = 0.5
table.row(1).text_color = '888892'
table.row(0).padding = [10, 15]
table.row(2).size = 11
end
end
...
end
The Results Section:
class PdfGeneratorService
...
def result_section
report_data = [['Report', '', 'View detailed list in new Reporting'],
['Name', 'Platform', 'Status'],
['gehpbatosid4pp_oak6f', "Apple Chibn \n f7euq", 'BLOCKED'],
['gehpbatosid4pp_oak6f', "Apple Chibn \n f7euq", 'PASSED']]
report_data_options = {
width: @document_width,
row_colors: ['ffffff'],
cell_style: {
border_width: 1,
borders: [:bottom],
border_color: 'c9ced5'
}
}
@pdf.table(report_data, report_data_options) do |table|
test_status = table.rows(1..-1).column(-1)
test_status.filter do |cell|
case cell.content
when 'BLOCKED'
cell.text_color = 'FDB357'
when 'PASSED'
cell.text_color = '83BC41'
# add more cases
end
end
table.row(0..1).border_width = 0.5
table.row(0).column(2).text_color = '2787c4'
table.row(0).column(2).size = 11
table.row(1).background_color = 'EDEFF5'
table.row(1..-1).column(1..2).align = :center
table.row(1).text_color = '465579'
table.row(1).size = 10
table.row(1..-1).padding = [10, 15]
table.row(0).padding = [7, 15]
end
end
...
end
We achieve this part with the code above.
And finally combining all the sections in the PDF.
class PdfGeneratorService
...
def generate_pdf
header
mid_section
lower_section
@pdf.render
end
...
end
The overall service.
# frozen_string_literal: true
class PdfGeneratorService
MONTSERRAT_FONT_PATH = 'app/assets/stylesheets/Montserrat-Medium.ttf'
MONTSERRAT_BOLD_FONT_PATH = 'app/assets/stylesheets/Montserrat-Black.ttf'
LOGO_IMG_PATH = 'app/assets/images/logo.png'
def initialize
@pdf = Prawn::Document.new
@document_width = @pdf.bounds.width
@pdf.font_families.update(
'montserrat' => {
normal: MONTSERRAT_FONT_PATH, # Path to the normal (regular) font style
bold: MONTSERRAT_BOLD_FONT_PATH # Path to the bold font style (if applicable)
}
)
end
def header
status_results = [%w[15 3 5 4 3], %w[TESTS FAILED PASSED BLOCKED UNKNOWN]]
text_colors = { 1 => 'C8102F', 2 => '83BC41', 3 => 'FDB357', 4 => 'BBBBBB' }
header_table = @pdf.make_table(status_results) do |table|
table.row(0).font_style = :bold
table.row(0).font = 'montserrat'
header_status_rows = [0, 1]
header_status_rows.each do |row_index|
text_colors.each do |column_index, color|
table.row(row_index).column(column_index).style(text_color: color)
end
end
table.row(0..1).border_width = 0
table.row(0..1).column(0).border_width = 1
table.row(0..1).column(0).border_color = 'c0c5ce'
table.row(0..1).column(0).borders = [:right]
table.row(0).size = 17
table.row(1).size = 11
end
header_column_widths = [@document_width * 2 / 3, @document_width * 1 / 3]
header_title_data = [['Execution Summary']]
header_title_options = {
column_widths: [@document_width],
row_colors: ['EDEFF5'],
cell_style: {
border_width: 0,
padding: [15, 12, 1, 20],
size: 20,
font: 'montserrat',
font_style: :normal
}
}
header_logo_data = [[header_table, { image: LOGO_IMG_PATH, position: :center, scale: 0.5, colspan: 6 }]]
header_logo_options = {
column_widths: header_column_widths,
row_colors: ['EDEFF5'],
cell_style: {
border_width: 2,
padding: [15, 15],
borders: [:bottom],
border_color: 'c9ced5'
}
}
@pdf.table(header_title_data, header_title_options)
@pdf.table(header_logo_data, header_logo_options)
end
def mid_section
mid_section_data = [['General', '', ''], ['Start time:', 'Duration:', 'Executed by:'],
["Apr 7, 2020 \n 10:33:57", '15m 9s', 'z-automation-sayvr@perfectmobile.com']]
mid_section_options = {
width: @document_width,
row_colors: ['ffffff'],
cell_style: {
border_width: 0,
borders: [:bottom],
border_color: 'c9ced5',
padding: [10, 15]
}
}
@pdf.table(mid_section_data, mid_section_options) do |table|
table.row(0).border_width = 0.5
table.row(1).text_color = '888892'
table.row(0).padding = [10, 15]
table.row(2).size = 11
end
end
def lower_section
report_data = [['Report', '', 'View detailed list in new Reporting'],
['Name', 'Platform', 'Status'],
['gehpbatosid4pp_oak6f', "Apple Chibn \n f7euq", 'BLOCKED'],
['gehpbatosid4pp_oak6f', "Apple Chibn \n f7euq", 'PASSED']]
report_data_options = {
width: @document_width,
row_colors: ['ffffff'],
cell_style: {
border_width: 1,
borders: [:bottom],
border_color: 'c9ced5'
}
}
@pdf.table(report_data, report_data_options) do |table|
test_status = table.rows(1..-1).column(-1)
test_status.filter do |cell|
case cell.content
when 'BLOCKED'
cell.text_color = 'FDB357'
when 'PASSED'
cell.text_color = '83BC41'
end
end
table.row(0..1).border_width = 0.5
table.row(0).column(2).text_color = '2787c4'
table.row(0).column(2).size = 11
table.row(1).background_color = 'EDEFF5'
table.row(1..-1).column(1..2).align = :center
table.row(1).text_color = '465579'
table.row(1).size = 10
table.row(1..-1).padding = [10, 15]
table.row(0).padding = [7, 15]
end
end
def generate_pdf
header
mid_section
lower_section
@pdf.render
end
end
A lot of things could have been done better, in this class:
- Create classes for each section and also put together similar styles and reuse them
Let’s create a controller to get this functionality working
Controllers:
class PdfGeneratorController < ApplicationController
def index;end
def generate_pdf
pdf_service = PdfGeneratorService.new
pdf_content = pdf_service.generate_pdf
pdf_filename = "test.pdf"
PdfMailer.send_report.deliver_now
File.open('test.pdf', 'wb') { |file| file.write(pdf_content) }
respond_to do |format|
format.pdf do
send_data pdf_content, filename: pdf_filename, type: 'application/pdf', disposition: 'attachment'
end
end
end
end
For sending the PDF attached to a mail. app/mailers/pdf_mailer.rb
Mailers:
class PdfMailer < ApplicationMailer
def send_report
@message = "Hey this is a result from your test suite."
pdf_content_path = generate_pdf_content
attachments['test_result_report.pdf'] = File.read(pdf_content_path)
mail(to: 'email@gmail.com', subject: 'Test Report Results.')
end
private
def generate_pdf_content
pdf_service = PdfGeneratorService.new
pdf = pdf_service.generate_pdf
pdf_file = Tempfile.new(['test_result_report', '.pdf'], Rails.root.join('tmp')) # store the generated pdf in tmp folder to be attached when sending mail.
pdf_file.binmode # converts the document content to binary mode. When you set a file to binary mode using binmode, it ensures that no newline character translations or character encoding conversions occur when reading or writing data
pdf_file.write(pdf)
pdf_file.rewind # move cursor to the top start of the page
pdf_file.close
pdf_file.path # generated pdf path
end
end
Binmode
: When you set a file to binary mode using binmode
, it ensures that no newline character translations or character encoding conversions occur when reading or writing data.
Views:
`app/views/pdf_generator/index.html.erb`
<div>
<h1>The PDF generator</h1>
<%= link_to 'Generate PDF', generate_pdf_path(format: :pdf), class: 'btn btn-primary' %>
</div>
`app/views/pdf_mailer/send_report.html.erb`
<div>
<h1>The PDF generator Tutorial</h1>
<p>
<%= @message %>
</p>
</div>
The final result would look like this;
Extra tasks you could try out;
- Add icons, using
prawn-icon
- Add footers at the bottom
- Use dynamic data from an actual source like a Model.
Important Links:
- https://github.com/prawnpdf/prawn-table
- https://github.com/jessedoyle/prawn-icon
- https://github.com/ryanb/letter_opener
- https://github.com/prawnpdf/prawn
You can find the working example via https://github.com/NicholusMuwonge/prawn_pdf. Feel free to comment with any questions and enhancements, I’m happy to learn from you all as well.
LinkedIn: linkedin.com/in/muwonge-nicholus-868468144/
Cheers 🥂.