Nutzer lieben Webanwendungen, die PDF-Dateien erzeugen. PDFs sind wie sauber ausgefüllte Papierformulare und Nutzer können sie ausdrucken, abheften, mit in ein Meeting nehmen, ... Kurz: Nutzer können weiter so arbeiten wie vor der Webanwendung, nur irgendwie moderner. Und genau das lieben sie.

Entwickler lieben Einfachheit und wollen Aufwände gern gering halten. Mit CSS muss man sich ja in jedem Falle auseinandersetzen. Wie wäre es, auch PDFs über CSS zu gestalten, nur eben mit Seitenzahlen, Kopf- und Fusszeile, ...

In unserem Artikel JRuby on Rails PDF Generierung haben wir schon vorgestellt, wie man einzelne Partials in PDFs bringen kann. Nun zeigen wir, wie eine normale Rails-Action einfach statt Anwendungs-HTML eine PDF-Datei erzeugt.
Nehmen wir einmal an, wir haben eine Liste von Nutzern. Vereinfacht haben wir dafür folgenden Quellcode
# in app/controllers/users_controller.rb
class UsersController < ApplicationController
 
  def  index
    @users = User.all
 
    respond_to do |format|
      format.html {} # index.html.erb
      format.xml { render :xml => @users}
    end
 end
end
# in app/views/users/index.html.erb
<h1> Nutzerliste</h1>
 
<table>
 
  #... Nutzerliste ...
</table>

Wie bekommen wir diese Ansicht in eine PDF?

Zunächst einmal installieren wir uns das Plugin acts_as_flying_saucer. Einmal installiert, mussten wir noch die benötigten Bibliotheken einbinden. Das ist notwendig, weil wir im JRuby die Java-Bibliothek direkt verwenden und nicht (wie in Ruby) auf ein externes Java zugreifen.
require 'java'
require File.expand_path(File.dirname(__FILE__) + '/java/jar/core-renderer.jar')
require File.expand_path(File.dirname(__FILE__) + '/java/jar/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'
Entweder man macht das direkt in der Plugin-Datei vendor/plugins/acts_as_flying_saucer/lib/xhtml2pdf.rb oder schafft sich ein Library-Datei, die man an entsprechender Stelle einbindet.

Ist das geschehen, müssen wir unserem Controller nur noch sagen, dass er PDF-Dateien erzeugen kann:
# in app/controllers/users_controller.rb
class UsersController < ApplicationController
  acts_as_flying_saucer
 
  def  index
    @users = User.all
 
    respond_to do |format|
      format.html {} # index.html.erb
      format.xml { render :xml => @users }
      format.pdf { render_pdf :template => "users/index", 
                                  :send_file => {:filename => "users_list.pdf"},
                                  :layout => "pdf.html.erb" }
    end
  end
end
Nur zwei Zeilen waren nötig: acts_as_flying_saucer macht den Controller PDF-fähig und mit render_pdf sagen wir ihm, welches Template er unter welchem Namen ausgeben soll. Wie man sieht, wird hier tatsächlich das gleiche Template genutzt wie bei der HTML-Erzeugung. Zusätzlich geben wir ihm noch ein eigenes Layout mit, welches weiter unten beschrieben wird.
Damit dieses Code-Beispiel funktioniert, muss man Rails noch den Mime-Type bekannt machen. Dazu reicht eine Zeile Zeile
# in config/initializers/mime_types.rb
Mime::Type.register "application/pdf", :pdf
Diese Vorgehensweise bietet den Vorteil, dass man in seinen HTML-Ansichten leicht einen PDF-Link für die aktuelle Seite unterbringen kann:
<%= link_to "Aktuelle Seite als PDF", url(:format => :pdf) %>

Wichtig zu wissen ist, dass Flying Saucer nur valides HTML akzeptiert und bei Fehlern mit wenig hilfreichen Exception-Meldungen aufwartet. Es muss also im Layout auf ein korrektes DOCTYPE geachtet und sich dann daran gehalten werden.

Um eine technische Trennung zu erreichen, kann man natürlich auch von einem eigenen Controller für alle PDFs ausgehen:
# in app/controllers/pdf_controller.rb
class PdfController < ApplicationController
  acts_as_flying_saurcer
 
  layout "pdf.html.erb"
 
  def index
    @users = User.all
         
    render_pdf :template => "users/index", 
                 :send_file => {:filename =>"users_list.pdf"}
  end
end
Da sich hier tendenziell aber der Controller-Code verdoppelt, ist diese Form eher geeignet, wenn man spezielle Zusammenstellungen hat, die gar nicht als eigene Anwendungsansicht existieren. Man muss so genau abwägen, welche dieser Möglichkeiten für den aktuellen Fall besser passt.

CSS-Definition

Einen besonderen Augenmerk muss man auf die Verwendung von CSS-Dateien legen. Wir haben in allen CSS-Dateien den Mediatyp "all" angegeben und sie auch mit diesem Typ ins HTML eingebunden. So haben wir unser Anwendungslayout prinzipiell auch in den PDF-Dateien zur Verfügung. Zusätzlich wird dann noch die "print.css" eingebunden, in denen die Ausnahmefälle verarbeitet werden. Da Flying Saucer zuerst die komplette HTML-Seite erstellt, speichert und diese dann unabhängig komplett in ein PDF verwandelt, müssen alle CSS-Dateien mit einem absoluten Pfad versehen werden. Dazu haben wir uns einen kleinen Helper definiert:
# in /app/controllers/application_controller.rb
 
helper_method :path_with_host
def path_with_host    
  File.join((request.protocol + request.host_with_port), relative_url_root || "")
end
# in views/layouts/pdf.html.erb

<link href="<%= path_with_host%>/stylesheets/content.css" 
       media="all" rel="stylesheet" type="text/css"/>
...
<!-- alle CSS-Dateien in dieser Form einbringen -->
...
<link href="<%= path_with_host%>/stylesheets/print.css"
       media="print" rel="stylesheet" type="text/css"/>

PDF-spezifische Header und Footer

Ein in PDF-Dateien wichtiges Feature sind Header- und Footer-Bereiche, die sich auf jeder Seite wiederholen. Flying Saucer unterstützt hier die entsprechenden CSS3-Eigenschaften.
@page {
  @bottom-right {content: "Seite " counter(page) " von " counter(pages))
  @top-left { padding-top: 1cm; vertical-align: top; content: element(pdf_top_left) }
}
 
#pdf_top_left { position: running(pdf_top_left) }
Dieses Stück CSS-Code setzt den Inhalt von zwei sogenannten Margin Boxes. Das sind Bereiche auf der PDF, die zwischen dem normalen Content-Bereich und dem eigentlichen Seitenrand liegen. Rechts unten wird direkt der gegebene content angezeigt. Dabei sind counter(page) und counter(pages) spezielle CSS-Befehle, die die aktuelle und die maximale Seitenanzahl zeigen. Durch die Verwendung dieser Befehle kann man den Inhalt nicht in ein eigenes Div auslagern, sondern muss ihn direkt in das CSS setzen. Deshalb empfiehlt es sich, diesen Bereich als Inline-CSS direkt in die Layout-Datei zu bringen. So kann man den Text auch mit Rails erzeugen (und bspw. übersetzen lassen) und man bringt nicht unnötig Inhaltselemente ins CSS.
Für den Header gehen wir einen anderen Weg, um Style und Inhalt besser zu trennen. Hier wird im Bereich @top-left bestimmt, dass ein Element mit dem Namen pdf_top_left angezeigt werden soll. Das zugehörige Element #pdf_top_left bekommt dann in seiner Positionsangabe mitgeteilt, dass es in den Header kommt. Wichtig ist, dass die Bezeichnungen innerhalb von element() und running() übereinstimmen.

Wie kommt nun der Inhalt in den Header? Wir definieren uns ein DIV innerhalb der Layoutdatei:
# in views/layouts/pdf.html.erb
<div id="pdf_top_left">
  <%= yield :pdf_header %>
</div>
Die genaue Position innerhalb der Datei ist dabei irrelevant, da das div durch die CSS-Positionsangabe in den Header geschoben wird. In der View wird dieses Div befüllt, wenn sie für die PDFs generiert wird. Dafür erweitern wir unsere Nutzerliste:
# in app/views/users/index.html.erb
 
<% if @pdf_mode %>
  <% content_for :pdf_header do %>
    Titel für Nutzerlisten-PDF
  <% end %>
<% end %>
 
<h1> Nutzerliste</h1>
 
<table>
  #... Nutzerliste ...
</table>

Tabellen-Header

Nun wird man recht bald auf einen weiteren Wunsch stoßen: Wenn in der PDF Tabellen erstellt werden, sollte der Header (und bei Bedarf auch der Footer) der Tabelle auf jeder Seite erscheinen. In HTML 4 wurden dafür die Elemente thead, tbody und tfoot eingeführt. Leider reicht das für Flying Saucer nicht aus. Es kennt aber einige andere Flags, die man in den CSS-Eigenschaften angeben kann:
table {
  -fs-table-paginate: paginate;
}
Eine Übersicht über die möglichen Schalter findet man in der aktuellen Doku (PDF) im Kapitel „Flying Saucer Extensions to the CSS 2.1 Specification"

... und dann noch aufräumen

Bei jeder erstellten PDF entstehen eine HTML- und eine PDF-Datei im Projekteigenen tmp-Ordner. In einer produktiven Anwendung können das sehr viele Dateien werden. Es empfiehlt sich also, einen Mechanismus zu bauen, der diese Dateien löscht. Es wäre zum Beispiel möglich, einen Cron-Job zu definieren, der alle Dateien mit einem gewissen Alter löscht.

Fazit:

Die PDF-Erstellung mit Flying Saucer und dem Rails-Plugin acts_as_flying_saucer macht Spaß, weil:
  • alle normalen Listen und Detailseiten gleichzeitig für HTML und PDF verwendet werden können
  • druckspezifische Features trotzdem leicht in die Anzeige integriert werden können
  • PDF-Dateien, die nicht in der Anwendung direkt vorkommen, trotzdem mit HTML so gestaltet werden, dass man sich als Entwickler von Webanwendungen zu Hause fühlt


weiterführende Links und Quellen
CSS3 Margin Boxes
Plugin acts_as_flying_saucer
Dokumentation von Flying Saucer (PDF)