Thousand Island
Dressing for Prawn
Prawn is awesome. It has some amazing functionality, and you can get anything that's in your head onto a PDF document with some Ruby code. For me though, as wonderful as that is, I normally only need a repeating header and footer, and then some text and maybe a table in between them. This is where Thousand Island comes in. A few simple commands should get you set up with a template that you can use application wide, and then all you need to worry about is getting the right content into the document.
Note: ThousandIsland is not meant to be a substitute for learning Prawn, you will get more out of it if you do. The excellent Prawn Manual is a great place to start.
Installation
Add this line to your application's Gemfile:
gem 'thousand_island'
And then execute:
$ bundle
Or install it yourself as:
$ gem install thousand_island
Usage
ThousandIsland uses the following classes to get the job done:
ThousandIsland::Template - The common layout and formatting for your pdfs live in here
ThousandIsland::Builder - Subclass the Builder for your actual business logic for the pdf itself
ThousandIsland::StyleSheet - A mixin for defining common styles to be used by your Template
A suggested directory structure for a Rails app is as follows, but it's entirely up to you:
app/
└── pdf_builders/
├── my_builder.rb
└── templates/
├── my_template.rb
├── my_style_sheet.rb
For a non-Rails application, the lib
directory can be used instead of the app
directory, but it's all up to you.
Creating a Template
The Template class is where you can define elements that may be common to all (or some) documents within your application. It is likely that a common style will be required, so defining it in a Template and then using that Template subclass in any custom Builders DRYs up your pdf generation, as well as allowing for easy restyling across the whole application.
Typically, the Template subclass would define the settings for the Prawn Document, as well as the settings for the header and footer. Add your own or override any existing settings in the settings
method. Any options passed into the constructor as a Hash will be merged with these settings, and the defaults.
Content for the header and footer will be defined in the methods header_content
and footer_content
. These methods are passed as a block when the pdf is rendered. Any standard Prawn methods may be used (including bounding boxes or any other layout tools). In addition, any of the styles from the StyleSheet
can be applied as helper methods. For instance, the default style sheet has a h1_style
method that returns a ThousandIsland::StyleHash, so in your code you can use:
h1 "My Document Header"
and Prawn will render the text in the style set in the h1_style
ThousandIsland::StyleHash.
In addition to the supplied style methods, you can create a custom method:
def magic_style
ThousandIsland::StyleHash.new({
size: 15
style: bold
})
end
As long as the method ends in the word "_style" and returns a Hash, you magically get to do this:
magic "My magic text is bold and size 15!!"
The method may return a standard Hash, but it is safer to return a ThousandIsland::StyleHash, as this dynamically duplicates a few keys to accommodate using the style in normal Prawn text methods as well as formatted text boxes, which use a slightly different convention. You don't have to worry about that if you use the ThousandIsland::StyleHash.
Alternatively, your method could do this:
def magic_style
h1_style.merge({
size: 15
style: bold
})
end
The following is an example of a custom template that subclasses ThousandIsland::Template -
class MyTemplate < ThousandIsland::Template
include MyCustomStyleSheet # optional
# settings here are merged with and override the defaults
def settings
{
header: {
height: 55,
render:true,
repeated: true
},
footer: {
render:true,
height: 9,
numbering_string: 'Page <page> of <total>',
repeated: true
}
}
end
def header_content
# Standard Prawn syntax
pdf.image "#{pdf_images_path}/company_logo.png", height: 30
end
def footer_content
# Using the magic method we get from the footer_style
footer "www.mycompanyurl.com"
end
def pdf_images_path
# How you go about this sort of thing is entirely up to you
"#{Rails.root}/app/assets/pdf_images"
end
end
Note: The Footer is a three column layout, with the numbering on the right column and the content defined here in the middle. More flexibility will be added in a later version.
Optional:
Add a body_content
method to add content before whatever the Builder defines in it's method of the same name.
StyleSheets
The StyleSheet is designed to be a mixin to the Template class. It may also be included into other modules to define custom StyleSheets.
Methods should return a StyleHash object rather than a vanilla Hash, as it has some customisation to help it work with Prawn. The default_style is used as the starting point for all other styles. For instance, the default_style[:size]
value is multiplied in the heading styles, so changing the default style size value will have a cascading effect. Check the source for the default values and override as preferred.
An example of a custom StyleSheet:
module MyStyleSheet
include ThousandIsland::StyleSheet
def default_style
super.merge({
size: 12,
color: '222222'
})
end
def h1_style
super.merge({ align: :center })
end
end
Creating a Builder
Your Builder class is where you will put the necessary logic for rendering the final pdf. It's up to you how you get the data into the Builder. It will depend on the complexity. You might just pass an Invoice object (MyBuilder.new(invoice))
) or you may have a bunch of methods that are called by an external object to get the data where it needs to be.
You must declare which Template class you will be using. Failing to do so will raise a TemplateRequiredError
when you call the build method. Declare the template with the following in the main class body:
uses_template MyTemplate
Your Builder can have a filename
method, which will help a Rails Controller or other class determine the name to use to send the file to the browser or save to the filesystem (or both). Without this method it will have a default name, so you may choose to put the naming logic for your file elsewhere, it's up to you.
You must have a body_content
method that takes no arguments (or the pdf will be empty!). This is the method that is passed around internally in order for Prawn to render what is in the method. You can use raw Prawn syntax, or any of the style magic methods to render to the pdf. You may also call other methods from your body_content
method, and use Prawn syntax and magic methods in those too.
A Builder example might be:
class MyBuilder < ThousandIsland::Builder
uses_template MyTemplate
attr_reader :data
def initialize(data={})
@data = data
# do something with the data...
end
def filename
"Document#{data.id_number}"
end
def body_content
# call custom methods, magic methods or call Prawn methods directly:
h1 'Main Heading'
display_info
body 'Main text in here...'
end
# Custom method called by body_content
def display_info
body "Written by: #{data.author}"
pdf.image data.avatar, height: 20
end
end
Finally, to get the finished pdf from your Builder, call the build
method like so:
pdf = my_builder.build
Optional:
Define a header_content
method to add content below whatever is defined in the Template. This will be repeated according to the header settings in the Template.
Define a footer_content
method to add content above whatever is defined in the Template. This will be repeated according to the footer settings in the Template.
Define a settings
method that returns a Hash. This will be passed to the Template class and will override any of the Template default settings.
Using the Builder in a Rails Application
Your Controller can look something like this:
def show
@thing = Thing.find(params[:id])
respond_to do |format|
format.html
format.pdf do
data = { thing: @thing } # How you structure this is up to you, it's your Class!!
builder = MyBuilder.new(data)
send_data builder.build, filename: builder.filename,
type: "application/pdf",
disposition: "inline" # Leave blank to render as a download
end
end
end
If your controller for getting the data for a PDF is that simple, then you're pretty lucky. Normally we're going to want a PDF file to render a few things at once, so you might build a service object that formats the data, and use as follows:
def show
respond_to do |format|
format.html do
@thing_for_html_view = Thing.find(params[:id])
end
format.pdf do
# Tell the service object to do it's thing and return the Builder
builder = ThingPdfServiceObject.new(params)
send_data builder.build,
filename: builder.filename,
type: "application/pdf",
disposition: "inline" # Leave blank to render as a download
end
end
end
These are only suggestions, as you can probably tell there is nothing tying you down to a particular way of building and delivering the document. You might even want to save it to the file system, or upload to S3. You could override the build method if you wanted to:
def build
pdf = super
# Save, upload, send or do whatever....
end
However, that kind of logic seems beyond the scope of the Builder, and should proabably be in the consumer of your Builder class, rather than the builder itself.
Tables
Quite often a pdf will need to render tabular data. It makes sense if there is a common table styling for all PDFs in your application (although it's not compulsory!). Thousand Island has a Table class, and a TableStyle class.
Note: Thousand Island has been designed to handle the most common scenarios, and therefore cannot cover every possibility and still keep it simple. If there's anything you can't do with a ThousandIsland::Table, you may need to use Prawn's standard table rendering. There's nothing stopping your Builder rendering a table without using any of the Table implementation in Thousand Island. Check out the Prawn Table Manual for the full capabilities.
Creating a Table
There are three main components of a table, header body and footer
Header
Header rows are optional. By doing nothing, your table will have no header row/s. To have a header in your table, you have several options:
# Straightforward single row, no builder logic required
header_rows = [['Column One', 'Column Two', 'Column Three']]
# Build gradually (You can use Prawn native syntax for span and formatting)
header_rows << [{content: 'First Two Columns', colspan: 2}, 'Third']
header_rows << ['Column One', 'Column Two', 'Column Three']
By default Thousand Island takes all the rows in the header_rows
and formats them as a repeating header. If you don't want a header, don't put anything in header_rows
. If you don't want the header to repeat across pages, then set:
header_repeat: false
in either the settings
method (which returns a hash specific to this Table class) or by setting it in you TableSettings custom class.
Body
Similar to header_rows
, body_rows
can be set in one go or build gradually. Given the nature of the content of a table, it is more likely you'll have a method in that iterates through a collection of objects and builds the table according to the requirements.
The way you build the Array of nested Array for the body is up to you. Three suggestions:
-
Simple Data: Simply set the
body_rows
as an Array of Arrays anywhere that's convenient -
Slightly Complex: Iterate over a collection craeting and Array of "cellable" objects for Prawn, and add to
body_rows
as you go (body_rows << newly_built_row
). You can do this in your Builder and pass it to the Table, or pass objects from your Builder to your Table so that the Table can deal with it. - Complex Real Life: Create a ServiceObject that knows everything it needs to know about how to build the rows. In some situations, a table row might need to represent multiple objects and the logic may be complex. Keeping the row building away from your Builder or Table classess keeps things simple.
Footer
Works in the same way as the header, except you use footer_rows
. The only difference is that the footer does not repeat.
Table Options
A PrawnTable is very configurable. The goal of the Table component of Thousand Island is to make the configuration reusable throughout your application. There is a TableSettings class (see below) that you can subclass and use as a configuration object, but you can also override those settings for a specific table by setting a compatible Hash in your Table class like so:
def settings
{
header_repeat: false
}
end
Table Settings
You can use the defaults, or create your own TableSettings
class as below:
class MyTableSettings < ThousandIsland::TableSettings
def table_options
{...}
end
end
All the settings in the table_options
Hash will be merged with the defaults and made available to any Table that uses your custom TableSettings class.
To get one of your Table subclasses to use your custom TableSettings subclass, do the following:
class MyTable < ThousandIsland::Table
uses_settings MyTableSettings
...
end
Using Tables in your Builder
Anywhere in your Builder, you can do:
table = table_with MyTable
This initialises the Table class and gets it ready to build the body rows. You can then use it where appropriate to render the table in your PDF document:
table.draw
# To override any of the settins either in the table or the TableSettings being used, just pass them as a Hash to the draw method:
table.draw(header_repeat: false)
Default Styles
Out of the box, ThousandIsland gives you some generic styles with default values. Override any of the values in your custom Stylesheet, or your Template. Create your own entirely new style in either of those places too, and get the magic method for free.
The default styles are:
body
{
size: 10, # Inherited from default_style
style: :normal, # Inherited from default_style
align: :left, # Inherited from default_style
leading: 1, # Inherited from default_style
inline_format: true, # Inherited from default_style
color: "000000" # Inherited from default_style
}
h1
{
size: 18, # Calcuated as 1.8 * default_style[:size]
style: :bold,
align: :left, # Inherited from default_style
leading: 8,
inline_format: true, # Inherited from default_style
color: "000000" # Inherited from default_style
}
h2
{
size: 15, # Calcuated as 1.5 * default_style[:size]
style: :bold,
align: :left, # Inherited from default_style
leading: 4,
inline_format: true, # Inherited from default_style
color: "000000" # Inherited from default_style
}
h3
{
size: 14, # Calcuated as 1.4 * default_style[:size]
style: :bold,
align: :left, # Inherited from default_style
leading: 4,
inline_format: true, # Inherited from default_style
color: "000000" # Inherited from default_style
}
h4
{
size: 11, # Calcuated as 1.1 * default_style[:size]
style: :bold_italic,
align: :left, # Inherited from default_style
leading: 4,
inline_format: true, # Inherited from default_style
color: "000000" # Inherited from default_style
}
h5
{
size: 10, # Calcuated as 1 * default_style[:size]
style: :normal, # Inherited from default_style
align: :left, # Inherited from default_style
leading: 4,
inline_format: true, # Inherited from default_style
color: "000000" # Inherited from default_style
}
h6
{
size: 8.5, # Calcuated as 0.85 * default_style[:size]
style: :italic,
align: :left, # Inherited from default_style
leading: 4,
inline_format: true, # Inherited from default_style
color: "000000" # Inherited from default_style
}
footer
{
size: 0.8, # Calcuated as 0.8 * default_style[:size]
style: :normal,
align: :left, # Inherited from default_style
leading: 1, # Inherited from default_style
inline_format: true, # Inherited from default_style
color: "666666"
}
Default Table Settings
Override any of these settings (or the nested settings) using the following techniques (in order of precedence):
- Builder:
table.draw(your_hash)
- Table class:
settings
method that returns a hash - TableSettings class:
table_settings
method that returns a hash
The full list of defaults
{
position: :center,
width: pdf.bounds.width, # Equates to full page width
cell_style: {
borders: [:top, :bottom],
border_width: 0.5,
inline_format: true,
size: 10
},
header_repeat: true,
header_format: {
align: :center,
font_style: :bold
}
}
To come...
- Easy list rendering and styling (including nested lists)
- More flexibility in the Footer layout
- (Possibly) Command line functions to create common subclass files
Contributing
- Fork it
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create new Pull Request