Viele Rails-Entwickler werden früher oder später auch einmal PDF-Dateien aus einer Anwendung heraus erzeugen wollen. Dafür gibt es viele denkbare Ansätze: angefangen von PDF::Writer über Prawn bis hin zu kommerziellen Produkten. (Ein Beispiel für PDF-Generierung mit Prawn findet man Ryan Bates Railscasts). Was uns an den meisten dieser Ansätze stört, ist der Aufwand, der mit dem neuen Layout für die PDF-Datei verbunden ist. Warum kann nicht auch hier eines der Grundprinzipien von Rails gelten - DRY (Don't Repeat Yourself)!
Unser Ziel war, bestimmte Bereiche der Rails-Views als Partials zu erzeugen, um genau diese Partials auch im PDF verwenden zu können -> Don't Repeat Yourself

Dank JRuby ist die Lösung dafür nicht schwer. In JRuby stehen uns ja neben den Ruby- und Rails-Bibliotheken auch die Vielfalt und Breite der Java Welt zur Verfügung. In dem Artikel Generating PDFs for Fun and Profit with Flying Saucer and iText wird an mehreren Beispielen sehr gut erläutert, wie man PDFs mittels Java erzeugt.

Durch die Java-Bibliothek iText und das "Flying Saucer"-Projekt ist man in der Lage, XML und CSS zu rendern und als PDF auszugeben. iText ist eine Java Bibliothek zum "on the fly" Generieren von PDF-Dateien. Sie benötigt mindestens JDK 1.4 und ist frei erhältlich. Das "Flying Saucer"-Projekt stellt einen XML/CSS Renderer zur Verfügung, der einen XML-Input mittels CSS formatiert und daraus eine Darstellung als Bild-, PDF- oder Bildschirmausgabe erstellt.

Das Ganze muss nun also nur noch in eine Rails-Anwendung eingebettet werden. Auf geht's!
In dieser Demo soll eine einfache TODO-Liste erstellt werden, die man als PDF speichern kann. Es wird also eine Liste von Aufgaben benötigt, die als erledigt oder offen gekennzeichnet werden können.

Die hier vorgestellte JRuby on Rails (JRoR) Anwendung ist vollständig als Quellcode vorhanden und sollte in einer bestehenden JRoR-Umgebung lauffähig sein. Zum besseren Verständnis erläuteren wir grob unsere Vorgehensweise.

Vorraussetzungen:

Die Anwendung wurde bei uns unter folgenden Bedingungen ausgeführt:
  • ein installiertes Java (mind. JDK 1.4)
  • JRuby 1.1.6
  • Rails 2.2 (Siehe dazu den Blogeintrag Installation von JRuby on Rails)
  • core-renderer.jar des "Flying Saucer" Projektes (ist im Beispiel "JRoR Projekt" enthalten)
  • iText Bibliothek, itext-paulo-155.jar (ist im Beispiel "JRoR Projekt" enthalten)
  • Mysql Datenbank
  • JRuby Gems, folgende sind bei uns vorhanden:
    • actionmailer (2.2.2)
    • actionpack (2.2.2)
    • activerecord (2.2.2)
    • activerecord-jdbc-adapter (0.9)
    • activerecord-jdbcmysql-adapter (0.9)
    • activeresource (2.2.2)
    • activesupport (2.2.2)
    • cgi_multipart_eof_fix (2.5.0)
    • columnize (0.2)
    • fastercsv (1.4.0)
    • gem_plugin (0.2.3)
    • htmlentities (4.0.0)
    • jdbc-mysql (5.0.4)
    • jruby-openssl (0.3)
    • mongrel (1.1.5)
    • rails (2.2.2)
    • rake (0.8.3)
    • ruby-debug (0.10.3)
    • ruby-debug-base (0.10.3)
    • ruby-debug-ide (0.4.2)
    • sources (0.0.1)


Vorbereiten der Rails-Anwendung:

Mittels des Scaffold-Generators legen wir eine Klasse Task an, die als Attribute nur eine Beschreibung und einen Zustand hat.
script/generate scaffold Task description:string finished:boolean
Das Grundgerüst des Demos steht. :-)

Damit unsere Task-Liste gleich ein paar Einträge hat, wurden in 001_create_tasks.rb gleich einige eingetragen. (Die Nummerierung der Migrationsscripte folgt im Beispiel noch an dem Format vor Rails 2.2.)
class CreateTasks < ActiveRecord::Migration
  def self.up
    create_table :tasks do |t|
      t.string  :description, :limit => 250, :null => false, :default => ""
      t.boolean :finished, :null => false, :default => 0

      t.timestamps
    end
    
    data
    
  end

  def self.data
    Task.create :description=>'TODO JRuby installieren'
    Task.create :description=>'TODO Rails installieren'
    Task.create :description=>'TODO notwendige Gems holen'
    Task.create :description=>'TODO Tasks anlegen'
  end

  def self.down
    drop_table :tasks
  end
end
In der Datei database.yml passen wir die Vorgaben für die 3 Datenbanken an. Als Adapter wird bei allen jdbcmysql eingetragen. Als Beispiel die Development-Datenbank:
development:
  adapter: jdbcmysql
  encoding: utf8
  database: jruby_create_pdf_demo_development
  username: root
  password:
  host: localhost
Die Datenbanken werden mittels
rake db:create:all
rake db:migrate
angelegt und initial befüllt.

Damit die Anwendung etwas freundlicher aussieht, haben wir das Layout und die Stylesheets angepasst und in der application.rb das Layout global festgelegt. Genauere Erläuterungen der Layout-Anpassungen würden hier den Rahmen sprengen. Wer mag, kann alle Anpassungen im Quellcode nachvollziehen.

Durch einige Anpassungen an den Dateien in \apps\view\tasks steht uns jetzt ein Partial zur Verfügung, das eine Tabelle mit den Aufgaben erzeugt _list.html.erb.
<div class="task_list">
  <table>
    <caption>Aufgaben</caption>
    <thead>
      <tr>
        <th>Beschreibung</th>
        <th>Erledigt</th>
        <th> </th>
        <th> </th>
      </tr>
    </thead>
    <tbody>
    <% for task in @tasks %>
      <tr class="<%= cycle('row1','row2') %>">
        <td><%=h task.description %></td>
        <td><%=h task.showState %></td>
        <td><%= link_to 'Editieren', edit_task_path(task) %></td>
        <td><%= link_to 'Löschen', task, :confirm => "Wollen Sie die Aufgabe wirklich löschen?", :method => :delete %></td>
      </tr>
    <% end %>
    </tbody>
  </table>
</div>
Die Anwendung ist nun so weit, dass wir uns dem eigentlichen Thema (der PDF-Erstellung) zuwenden können.

Druck der Aufgabenliste als PDF

Der Link für die PDF-Ausgabe soll gleich unterhalb der Aufgabentabelle sein, daher wurde der entsprechende Link unterhalb des Partials in views\tasks\index.html.erb eingefügt.
<p><%= link_to 'Neue Aufgabe', new_task_path %></p>
<%= render :partial=>"list"%>
<% pdf_title = "PDF-Druck der angezeigten Aufgaben"
   pdf_link  = link_to(image_tag("pdf.gif", :size=> "28x16", :title=>pdf_title, 
                                 :alt=>pdf_title) + " Aufgaben als PDF drucken", 
                                 formatted_tasks_path(:pdf), :target => "_blank") 
%>
<span class="tasks_actions"><%= pdf_link %></span>
Es wird ein Link mit Bild und Text angezeigt, der eine Aktion mittels formatted_tasks_path(:pdf) aufruft. Daraufhin wird im tasks_controller.rb die index Aktion angesprochen und zwar als PDF-Anfrage.
def index
  @tasks = Task.find(:all)

  respond_to do |format|
    format.html # index.html.erb
    format.pdf do
      index_pdf # Methode zum Erzeugen des PDFs
    end
  end
end
Damit die Anfrage auch als PDF erkannt wird, muss man in config\initializers\mime_types.rb folgendes angeben.
Mime::Type.register 'application/pdf', :pdf
Das eigentliche Anstoßen der PDF-Erstellung wird nun in der privaten Methode index_pdf im tasks_controller.rb ausgeführt.
def index_pdf
  @tasks = Task.find(:all)
  tasktable = render :partial=>"tasks/list.html.erb", 
                     :locals => {:tasks => @tasks}
    
  #Beginn PDF Erstellung
  buf = Pdf.start_buffer
  Pdf.write_html_header(buf, false)
  buf.append("<body>");
  buf.append("<h2>PDF Druck Demo</h2>")     
  buf.append("<p>Beispiel, wie man mittels JRuby in einer Rails Anwendung ein PDF erstellen kann. <br/>http://www.jror.de </p>")     
  buf.append(tasktable)
  buf.append("</body>");
  buf.append("</html>");
    
  filename = Time.now.strftime("%Y_%m_%d")+"_jror_pdf_druck_demo.pdf"
  pdftempfile  = Pdf.create(buf, filename)
  # Ende PDF Erstellung
    
  show_pdf_with_delete_pdftempfile(pdftempfile, filename)
end
Die Methode holt sich alle Aufgaben, rendert die Aufgabentabelle und beginnt mit dem Erzeugen des PDFs, indem Methoden aus dem Pdf-Modul aufgerufen werden. Anschließend wird ein Browserdialog zum Öffnen oder Speichern des PDFs gestartet und die temporär erzeugte Datei gelöscht.

Wie funktioniert das nun genau?

An dieser Stelle kommen neben JRuby auch die beiden oben schon erwähnten Java-Bibliotheken (core-renderer.jar und itext-paulo-155.jar) zum Einsatz.

Die beiden Jar's liegen im Projekt unter lib\java. Des Weitern gibt es unter lib eine Datei pdf.rb, welche die eigentliche Erzeugung des PDFs übernimmt. Das Pdf-Modul wird in der application.rb für die ganze Anwendung bekannt gemacht.
 require_dependency 'pdf'
Das Pdf-Modul ist die Schnittstelle zu Java. Es inkludiert die benötigen Java-Klassen.
require 'java'
require 'lib/java/core-renderer.jar'
require 'lib/java/itext-paulo-155.jar'

include_class 'java.io.FileOutputStream'
include_class 'java.io.ByteArrayInputStream'
include_class 'java.io.StringBufferInputStream'
include_class 'java.io.InputStreamReader'
include_class 'java.lang.StringBuffer'
include_class 'javax.xml.parsers.DocumentBuilder'
include_class 'javax.xml.parsers.DocumentBuilderFactory'
include_class 'org.w3c.dom.Document'
include_class 'org.xhtmlrenderer.pdf.ITextRenderer'
include_class 'org.xhtmlrenderer.pdf.ITextFontResolver'
include_class 'com.lowagie.text.pdf.BaseFont'
Der Vorgang ist dann wie folgt: Es wird eine XHTML-Struktur aufgebaut, samt <head>- und <body>-Tags. Diese Struktur wird in einem java.io.StringBuffer gespeichert und anschließend dem Pdf-Modul zum Parsen des HTML's und Erzeugen des PDFs übergeben. Um das Parsen kümmert sich die gleichnamige Methode.
# erstellt das PDF und legt es als Datei ab
# der Pfad zur Datei wird zurueckgegeben
def Pdf.parse(buf, file_name="demo")    
  builder = DocumentBuilderFactory.newInstance().newDocumentBuilder()
  sbis = StringBufferInputStream.new buf.toString()
  doc = builder.parse(sbis)

  i_text_renderer = ITextRenderer.new
  i_text_renderer.setDocument(doc, nil)

  file = File.join(RAILS_ROOT, TMP_FOLDER, file_name+".pdf")

  fos = FileOutputStream.new(file)
  i_text_renderer.layout()
  i_text_renderer.createPDF(fos);
  fos.close();  
   
  file
end
Der Stringbuffer und optional ein Dateiname wird übergeben, das PDF erzeugt und der Pfad zu der erzeugten PDF-Datei zurückgeben.

Im Detail: Der XHTML-Renderer erstellt aus einem übergebenen String ein Dokument. Dieses Dokument wird wiederum an den iText-Renderer übergeben der daraus das PDF erstellt und im FileOutputStream ausgibt.

Das PDF liegt fertig auf der Platte. :-)

Nun muss es dem Anwender nur noch zum Öffnen oder Speichern angezeigt werden. Dazu wird die Rails-Methode send_data genutzt, die in der Methode show_pdf_with_delete_pdftempfile(pdftempfile, file_name) in application.rb aufgerufen wird.
# oeffnet den BrowserDialog zum Speichern oder Oeffnen des PDFs
# Das PDF wird anschliessend geloescht.
def show_pdf_with_delete_pdftempfile(pdftempfile, file_name)
  if (nil != pdftempfile)
    send_data(IO.read(pdftempfile), :filename => file_name, :type => "application/pdf")
    File.delete("#{pdftempfile}")
  end
end
Und Voilà - wir haben ein PDF!

Einen Verbesserungswunsch müssen wir aber gleich noch anmerken:

Es wäre doch besser, wenn wir das PDF ohne Umweg der temporären Speicherung ausgeben könnten. Wir freuen uns über jeden Vorschlag, wie man iText dazu bringt, nicht erst ein PDF auf der Festplatte abzulegen, sondern gleich ohne Umweg die Binärdaten an send_data übergibt.
komplette Rails-Anwendung zum Tutorial (zip ca. 2,5MB)

Generating PDFs for Fun and Profit with Flying Saucer and iText
Flying Saucer User's Guide
iText

weitere PDF-Erstellungstutorials
Using iText to generate PDFs in Rails
HTML / CSS to PDF using Ruby on Rails
Howto Generate PDF files in Ruby on Rails using JRuby and iText
Tutorial on producing PDF file using JRuby and IText
How To Generate PDFs in Rails With Prawn