Just listen to Alex

May 1, 2009

JRuby on Rails, PDF generation and Flying Saucers

Filed under: Uncategorized — bosmeeuw @ 2:01 pm

There are tons of ways to generate PDF documents in just about any language. If you’re using Ruby, you have a number of libraries at your disposal to generate PDF documents using the specific API’s of these libraries. But if you have a Rails or other Ruby web application, the primary goal of your application is outputting HTML to be displayed in clients web browsers. So, wouldn’t it be nice to generate your PDF reports the same way, by outputting HTML?

If you’re running JRuby on Rails, this is a piece of cake! Using the great Flying Saucer Project (AKA xhtmlrenderer), you can convert your HTML + CSS to PDF documents. The great thing about Flying Saucer is that it will mostly render your HTML to PDF the same way standard compliant browsers will render it to screen. You have most of CSS 2.1 at your disposal, plus some custom CSS properties to handle print-specific matters such as paginated tables, and complex headers and footers. It has suprisingly few bugs, which really is an achievement when you’re talking about a HTML renderer. Did I mention it’s pretty fast, even for very large documents?

Of course, this is a Java library, so you need to be running JRuby to use it from your Rails application (or you need to resort to interfacing with it through the command line). First thing you need to do is installing the Flying Saucer gem, provided by the Wolfe project:

jruby -S gem install flying_saucer

This will automatically download the needed java library jars (flying saucer itself and iText) and put them in your gems folder.

Next, create a Rails controller you will use to output your PDF documents, for instance “ReportingController”. Let’s have it output a basic Hello World PDF.

The code for the controller (note the “render_pdf” method):

require "java"
require "flying_saucer"

class ReportingController < ApplicationController
  layout "pdf"

  def hello
    @text = "Hello world"

    render_pdf("hello.pdf")
  end

  private

  def render_pdf(filename)
    @html = render_to_string

    html_file = java.io.File.createTempFile(params[:action], ".html")
    html_file.delete_on_exit

    pdf_file = java.io.File.createTempFile(params[:action], ".pdf")
    pdf_file.delete_on_exit

    file_output_stream = java.io.FileOutputStream.new(html_file)
    file_output_stream.write(java.lang.String.new(@html).get_bytes("UTF-8"))
    file_output_stream.close

    renderer = org.xhtmlrenderer.pdf.ITextRenderer.new

	# if you put custom fonts in the lib/fonts folder under your Rails project, they will be available to your PDF document
	# Just specify  the correct font-family via CSS and Flying Saucer will use the correct font.
    fonts_path = RAILS_ROOT + "/lib/fonts/*.ttf"

    if File.exist?(fonts_path)
      font_resolver = renderer.getFontResolver()

      Dir[fonts_path].each do |file|
        font_resolver.add_font(file, true)
      end
    end

    renderer.set_document(html_file)
    renderer.layout
    renderer.createPDF(java.io.FileOutputStream.new(pdf_file), true)

    send_file pdf_file.path, :type => "application/pdf", :filename => filename, :disposition => "attachment"
  end
end

The main layout for our PDF documents:

<html>
   <head>
      <style type="text/css">
         body {
            font-family: sans-serif;
         }
      </style>
   </head>
   <body>
      <h1>My PDF Wielding Application Title</h1>

      <%= yield %>
   </body>
</html>

And the code to the view for the “hello” action:

Cliché message goes here: <strong><%=h @text %></strong>

Surfing to /reporting/hello will generate this PDF document.

So, the gist is: render all you want (as long as it’s properly escaped XHTML), call the render_pdf method and voila, instant PDF! Feel free to re-use your existing partials and helpers, it’s all good.

One remark: if you refer to resources like stylesheets and images, you must use an absolute URL or FlyingSaucer won’t be able to access them. For instance, use:

<link rel="stylesheet" type="text/css" href="http://app.example.com/stylesheets/reports.css" />

In stead of:

<link rel="stylesheet" type="text/css" href="/stylesheets/reports.css" />

Happy reporting!

January 17, 2009

Easily repeating HTML form sections without (much) javascript

Filed under: programming — Tags: , , — bosmeeuw @ 2:49 pm

If you’ve written more than a few data-driven web applications, you’ve surely encountered data models where a “master object” has multiple “child objects”.

In this example, we’ll use a book which can have many authors.
You could have the user enter the data for the book, and then add the authors one by one using multiple HTTP requests. Of course, this way the user needs to make many round trips to the server, which might slow him down considerably.

So you accomodate the user and write some javascript to enable him to add the X authors before posting anything to the server, yielding something like this:

Screenshot of expanding form (icons by famfamfam.com)

Screenshot of expanding form (icons by famfamfam.com)

There’s many approaches to programming this feature, the worst one being manually concatening HTML in a Javascript function and appending it to a container’s innerHTML attribute.

Below, you can find some code which allows you to create a repeating form like the one in the screenshot with just 3 lines of javascript. The idea is to create your repeating form element in plain HTML, and then attach some javascript behaviour to it which enables the user to repeat the form element. The Prototype Javascript library is required.

Here’s the code for the form:

<h1>Add a book</h1>

<form method="POST" action="/books/save_book">
   <table class="horizontal-layout">
      <tr>
         <td class="left">
            <h2>Data</h2>

            <table class="form">
               <tr>
                  <th>Title</th>
                  <td>
                     <input type="text" name="book[title]" />
                  </td>
               </tr>
               <tr>
                  <th>Description</th>
                  <td>
                     <textarea name="book[description]" class="medium"></textarea>
                  </td>
               </tr>
               <tr>
                  <th></th>
                  <td>
                     <input type="submit" class="submit" value="Save book &amp; authors" />
                  </td>
               </tr>
            </table>
         </td>
         <td>
            <h2>
               Authors
               <img src="/images/icons/page_white_add.png" id="add-author" class="link" />
            </h2>

            <div>
               <div
                  id="author-element"
                  class="expandable-form-entry"
                  style="line-height: 2em;"
               >
                  <input type="hidden" name="author[#index][id]" />

                  <img src="/images/icons/page_white_delete.png" class="delete-entry link" />

                  <strong>Author name:</strong>
                  <br />
                  <input type="text" class="required" name="authors[#index][name]" />

                  <br />

                  <strong>Author Remarks:</strong>
                  <br />
                  <textarea class="small" name="author[#index][remarks]"></textarea>
               </div>
            </div>
         </td>
      </tr>
   </table>
</form>

<script type="text/javascript">
   var authorsExpander = new ExpandingFormElement({
      entryModel: 'author-element',
      addEntryLinkElement: 'add-author',
      deleteEntryElementClass: 'delete-entry',
      deletionConfirmText: "Are you sure you want to delete author?"
   })
</script>

Notice the input elements for the authors are named “author[#index][field_name]“. Each input name must contain the string “#index”, as the javascript code will replace this by the correct index of the element in the form. So if the user adds three authors, the third element will contain elements “author[2][name]” and “author[2][remarks]“, which you can easily save to your database server side.

The javascript at the bottom couples the expanding element behaviour and has these options:

  • entryModel: the id of the element you want to use as the model for the repeating element
  • addEntryLinkElement: the element the user will click to add a new entry
  • deleteEntryElementClass: the css class of the element the user can click to remove and added entry
  • deletionConfirmText: the text to confirm deletion of an entry (leave empty if you don’t want to ask for confirmation)

After you’ve saved the data to your database, the user might want to edit the data. This means you need to re-populate the data back to the form. This can be done using the addEntry() method of ExpandingFormElement, which can take a hash containing the data for the entry. The keys of the hash must correspond with the field name (the part after the [#index]). In a ruby on rails application, you could populate the data like this:

var authorsExpander = new ExpandingFormElement({
  entryModel: 'author-element',
  addEntryLinkElement: 'add-author',
  deleteEntryElementClass: 'delete-entry',
  deletionConfirmText: "Are you sure you want to delete author?"
})

<% @book.authors.each do |author| %>
	authorsExpander.addEntry(<%= author.attributes.to_json %>)
<% end %>

In PHP, you might use the json_encode() function on an associative array containing your author data.

Here’s the javascript code for the ExpandingFormElement class. The Prototype javascript library is required.

var ExpandingFormElement = Class.create({
    initialize: function(options) {
        this.options = options

        this.entryModel = $(options.entryModel)
        this.container = $(this.entryModel.parentNode)

        this.container.cleanWhitespace()

        if(this.container.childNodes.length > 1) {
            throw new Error("The container (parentNode) of the entryModel must contain only the entryModel, and no other nodes (put it in a <div> of its own). The container has " + this.container.childNodes.length + " elements after white space removal.")
        }

        this.entryModel.remove()

        $(options.addEntryLinkElement).observe('click',function() {
            this.addEntry()
        }.bind(this));
    } ,

    addEntry: function(values) {
        var copiedElement = this.entryModel.cloneNode(true)

        this.observeCopiedElement(copiedElement)

        var index = this.getNumberOfEntries()

        this.replaceInputNamesInElement(copiedElement, index)

        this.container.appendChild(copiedElement);

        if(values != null) {
            this.setEntryValues(copiedElement, values)
        }
    } ,

    setEntryValues: function(element, values) {
       $H(values).each(function(entry) {
          var input = this.getInputFromElementByName(element, entry.key)

          if(input) {
              input.value = entry.value;
          }
       }.bind(this));
    } ,

    getInputFromElementByName: function(element, name) {
        var matchedInput = null;

        var inputs = element.select('input','textarea','select')

        inputs.each(function(input) {
           if(input.name.indexOf("[" + name + "]") != -1) {
               matchedInput = input;

               return $break;
           }

           return null;
        });

        return matchedInput;
    } ,

    getNumberOfEntries: function() {
        return this.container.childNodes.length
    } ,

    observeCopiedElement: function(element) {
        var deleteEntryElement;

        if((deleteEntryElement = element.down('.' + this.options.deleteEntryElementClass))) {
            deleteEntryElement.observe('click',function() {
                if(this.options.deletionConfirmText) {
                    if(confirm(this.options.deletionConfirmText)) {
                        element.remove()
                    }
                }
                else {
                    element.remove()
                }
            }.bind(this))
        }
    } ,

    replaceInputNamesInElement: function(element, index) {
        $(element).select("input","textarea","select").each(function(input) {
            input.name = input.name.replace("#index",index)
        }.bind(this))
    }
});

Here’s the CSS I used for this example:

div.expandable-form-entry {
    position: relative;
    border-top: 1px dotted silver;
    padding-top: 1em;
    margin-bottom: 1em;
}

div.expandable-form-entry img.delete-entry {
    position: absolute;
    right: 0;
}

Blog at WordPress.com.