Skip to main content

Generowanie dokumentów PDF na podstawie szablonów - Apache Velocity i Apache FOP

Czasem się zdarza, że implementuję jakiś kawałek kodu, który ma za zadanie wygenerowanie pliku PDF. Do tej pory używałem biblioteki iText, lecz nie jest ona zbyt wygodna. Trudno mi sobie wyobrazić jej zastosowanie w przypadku gdybym chciał generować PDFy na podstawie jakiś szablonów, tak by unikać modyfikacji w kodzie jeśli chcemy zmienić wygląd wynikowego pliku. I tu właśnie pojawia nam się świetny tandem, który doskonale pasuje do realizacji takiego zadania: Apache Velocity i Apache FOP.

    Użyte komponenty:
  • Apache Velocity (mechanizm template'owania)
  • Apache FOP (Formatting Objects Processor - konwersja XSL-FO na PDF i inne formaty)
    Formaty danych:
  • XSL-FO - język stosowany do formatowania dokumentów
    Jakie są korzyści korzystania z takiego zestawu:
  • wygląd dokumentu wynikowego, jest całkowicie wyjęty poza aplikację, można go niemal dowolnie modyfikować co nie wymaga żadnych zmian w aplikacji (zakładając, że szablon jest dostarczany z zewnątrz)
  • jest jeden kod który może odpowiadać za generowanie wielu dokumentów (z dokładnością do dostarczenia różnych danych jakie są potrzebne do różnych szablonów)
  • XSL-FO ma potężne możliwości, bardziej skomplikowane formatowania tekstu wydają się dużo łatwiejsze do uzyskania, niż za pomocą np. biblioteki iText (bo tam wszystko to trzeba zrobić kodem Javy)
  • praca nad formą (wyglądem) dokumentu wynikowego jest dużo szybsza, nie trzeba rekompilować i redeployować aplikacji, zmienia się tylko szablon i z interfejsu ponownie uruchamia generowanie
  • ponieważ Adobe nie obsługuje "od ręki" polskich znaków diakrytycznych (ęóąśłżźćń), konieczne jest wbudowanie do każdego dokumentu, który takowe znaczki posiada, odpowiednich czcionek (posiadających takie znaczki). Wbudowanie takich czcionek zapewni nam, że we wszystkich możliwych konfiguracjach polskie znaczki się pojawią. Pobocznym efektem będzie również to, że również krój czcionki będzie identyczny dla wszystkich możliwych konfiguracji. Nie wiem, czy jest to możliwe w przypadku iTexta. W Apache FOP jest to dość łatwe. Konieczne jest wygenerowanie metryczek dla czcionek, które chcemy wbudować (za pomocą dołączonego prostego narządka), dokonania odpowiednich wpisów w pliku konfiguracyjnym i dopilnowaniu by rzeczone pliki czcionek wraz z ich metryczkami, znalazły się w zasięgu silnika FOP.
    Niedogodności / ograniczenia (te na które wpadłem):
  • trzeba określić dość sztywno jakie dane muszą być pobierane dla konkretnego szablonu. Potem jeśli osoba edytująca szablon zdecyduje się zrezygnować z umieszczenia jakichś danych w dokumencie wynikowym, to aplikacja i tak je będzie pobierała z bazy, tylko że silnik velocity je zignoruje. Nie szkodzi, jeśli są to pojedyncze napisy, ale jeśli wycina się z dokumentu wynikowego jakąś tabelę na 6 stron to lepiej już wyciąć pobieranie tych danych także i z kodu. Podobnie jeśli trzeba dorzucić dane których wcześniej nie pobieraliśmy, konieczna będzie modyfikacja kodu i rebuild. Tak czy inaczej jest to i tak o wiele bardziej wygodnie niż przy korzystaniu np. z iText.
  • pewną niedogodnością jest fakt, że gdy chcemy używać wbudowanych czcionek to musimy podawać nie tylko nazwę jaką im przypisaliśmy w konfiguracji (atrybut font-family) ale również przez wskazanie konkretnego stylu i pogrubienia (odpowiednio font-style i font-weight). Pominięcie któregoś z tego zestawu atrybutów spowoduje, że silnik nie rozpozna, że chodzi nam o konkretną, wbudowaną czcionkę i użyje jakiejś domyślnej. Wynika to zapewne ze sposobu w jaki Java identyfikuje czcionki, konkretna czcionka to jej krój + jej styl + jej pogrubienie.
    Opis rozwiązania:
  1. Klasa serwująca - servlet, na podstawie otrzymanych w żądaniu parametrów (identyfikator szablonu) wywołuje w klasie generującej metodę pobierającą dany szablon.
  2. Klasa generująca uruchamia z odpowiedniej fasady metodę pobierająca tekst szablonu (z bazy danych/pliku/z innego systemu)
  3. Klasa serwująca wywołuje metodę odpowiedzialną za dokonanie przekształceń za pomocą Velocity, przekazując do niej oczywiście tekst oraz identyfikator szablonu.
  4. Klasa generująca za pomocą odpowiednich fasad pobiera z warstwy persystencji aplikacji dane odpowiednie dla przekazanego szablonu.
  5. Klasa generująca zasila kontekst silnika Velocity tymi danymi.
  6. Klasa generująca uruchamia silnik velocity i zwraca odpowiednio przekształcony plik XSL.
  7. Klasa serwisująca uruchamia metodę odpowiedzialną za wygenerowanie dokumentu wynikowego, przekazując otrzymany w poprzednim kroku XSL oraz referencję do strumienia wyjściowego servletu.
  8. Klasa generująca inicjuje transformatę i procesor FOP oraz uruchamia cały proces.
  9. Dokument wynikowy zapisywany jest do wskazanego strumienia.
  10. Koniec pracy.
U W A G A !!!
Ponieważ inicjowanie fabryki procesora FOP i przypisanie jej konfiguracji jest dość kosztowne
należy starać się o ile to możliwe używać ponownie tej samej instacji fabryki. Jednocześnie
przy każdym generowaniu musi powstawać nowa instancja agenta (FOUserAgent).

Prosty diagram obrazujący w uproszczeniu cały mechanizm:

+-----------------------------+
|                             |
| XML ze znacznikami Velocity |
|    w formacie XSL-FO        |
|                             |
+-----------------------------+
               |
               |                         
               |                         
               |   /-------------------------\
               |  |                           |
               |  |      Pobranie danych,     |
               |  |      Silnik Velocity      |
               |  |                           |
               |   \-------------------------/
               |                         
               |                         
               |
              \ /
               V
+-----------------------------+
|                             |
| XML w formacie XSL-FO, ale  |
|  już z danymi z aplikacji   |
|                             |
+-----------------------------+
               |
               |                         
               |                         
               |   /-------------------------\
               |  |                           |
               |  |    Procesor Apache FOP    |
               |  |                           |
               |   \-------------------------/
               |                         
               |                         
               |
              \ /
               V
+-------------------------+
|                         |
| PDF zbudowany na bazie  |
|    źródłowego XMLa      |
|                         |
+-------------------------+

    Poniżej znajduje się przybliżony opis jak wyglądają poszczególne kroki już w kodzie:
  1. Mamy szablony w postaci XML pobierany z systemu zewnętrznego (lub z pliku/bazy danych). Ten XML jest formacie zgodnym z XSL-FO (w zakresie obsługiwanym przez Apache FOP) i zawiera znaczniki rozpoznawane przez Velocity

    Poniżej fragment przykładowego XMLa:

    <fo:table table-layout="fixed" width="100%" space-after="10pt">
         <fo:table-body>
              <fo:table-row>
                   <fo:table-cell>
                        <fo:block font-family="FreeSans" font-style="normal" font-weight="normal" font-size="8pt">$parameter.applicationType</fo:block>
                   </fo:table-cell>
                   <fo:table-cell>
                        <fo:block font-family="FreeSans" font-style="normal" font-weight="normal" font-size="8pt" text-align="right">Numer wniosku: $parameter.applicationNumber</fo:block>
                   </fo:table-cell>
              </fo:table-row>
         </fo:table-body>
    </fo:table>
    <fo:table table-layout="fixed" width="100%" space-after="10pt">
         <fo:table-body>
              <fo:table-row>
                   <fo:table-cell>
                        <fo:block font-family="FreeSans" font-style="normal" font-weight="normal" font-size="10pt">Miejscowość: $parameter.applicationCity</fo:block>
                   </fo:table-cell>
                   <fo:table-cell>
                        <fo:block font-family="FreeSans" font-style="normal" font-weight="normal" font-size="10pt" text-align="right">$parameter.currentDate</fo:block>
                   </fo:table-cell>
              </fo:table-row>
         </fo:table-body>
    </fo:table>

    Tagi z przestrzeni nazw 'fo' to tagi zwiazane z formatowaniem z XSL-FO a napisy zaczynające się od znaku dolara '$' to znaczniki velocity. Zostaną one zastąpione danymi z aplikacji. Są również znaczniki sterujące np. warunkowe (w zależności od wartości jakichś danych fragmenty "wynikowego" XMLa mogą się pojawiać lub nie) lub iterujące po jakichś danych (idealne np. przy tworzeniu wierszy w tabelkach).

  2. Tworzymy metodę, w której inicjujemy silnik Velocity:
         Velocity.init();
         VelocityContext vctx = new VelocityContext();
  3. Zasilamy kontekst silnika danymi z aplikacji. Powinien to być ustalony zestaw danych, z których będzie można skorzystać w szablonie.
    Robi się to np. tak:

         vctx.put("parameter", appDataMap);

    Gdzie appDataMap to mapa z potrzebnymi nam danymi, "parameter" zaś nazwą do której bedą odwoływały się znaczniki Velocity np. w taki sposób:

         <fo:block>$parameter.currentDate</fo:block>

    Jeśli pod kluczem "currentDate" w mapie istnieje obiekt Date albo String ze sformatowaną już datą. Oczywiście nie musimy używać mapy. Można wrzucać bezpośrednio napisy albo jakieś aplikacyjne obiekty, do których wartości odwołujemy się intuincyjnie jak zwykle z prostymi bean'ami w wielu innych bibliotekach, np.:

         vctx.put("currentDate", new Date());
         vctx.put("userData", userData);

    By potem odwoływać się do nich tak:

         <fo:block>$currentDate</fo:block>
         <fo:block>$userData.firstName $userData.lastName</fo:block>
         <fo:block>Miasto: $userData.address.city</fo:block>

  4. Po dostarczeniu danych możemy uruchomić kod interpretujący znaczniki Velocity:

         // ponizej nalezy w jakis sposob pobrac szablon
         // i zapisac go w zmiennej "template".
         String template = ...;
         // uruchomienie silnika
         StringWriter content = new StringWriter();
         Velocity.evaluate(vctx, content, "", template);
         String sourceXml = content.toString();

  5. W zmiennej "sourceXml" mamy teraz XMLa powstałego na podstawie szablonu z powstawianymi już właściwymi wartościami z dostarczonych danych. Jeśli szablon zawierał także instrukcje sterujące z Velocity, zostały one właściwie zinterpretowane a właściwe fragmenty szablonu XML odpowiednio przekształcone.
  6. Przygotowanie fabryki silnika FOP i dostarczenie jej obiektu konfiguracji (bazującego na pliku konfiguracyjnym), przygotowanie agenta oraz jego ewentualna konfiguracja:

         FopFactory fopFactory = FopFactory.newInstance();
         DefaultConfigurationBuilder cfgBuilder = new DefaultConfigurationBuilder();
         Configuration cfg = cfgBuilder.buildFromFile(new File("fop.conf.xml"));
     
         fopFactory.setUserConfig(cfg);
     
         FOUserAgent foUserAgent = fopFactory.newFOUserAgent();
         // tutaj konfiguruj agenta wedle uznania i potrzeb

  7. Przygotowanie strumienia wyjściowego do którego zapisywany będzie wynik. Może to być np. strumień wyjściowy dla pliku lub dla servleta:

         // ponizej pobierz strumień z servleta lub dla pliku
         OutputStream out = ...;
         BufferedOutputStream bout = new java.io.BufferedOutputStream(out);

  8. Przygotowanie procesora i transformaty:

         // Tworzy procesor i ustawia odpowiedni format wynikowy (nie musi być PDF)
         Fop fop = fopFactory.newFop(MimeConstants.MIME_PDF, foUserAgent, out);
     
         // Ustawienia transformaty
         TransformerFactory factory = TransformerFactory.newInstance();
         Transformer transformer = factory.newTransformer();
     
         // Ustawienie wartości <param> dla stylesheety
         transformer.setParameter("versionParam", "2.0");

  9. Uruchomienie transformaty i procesora:

         // Ustawienia wejścia dla transformaty XSL
         Source src = new StreamSource(new StringReader(sourceXml));
     
         // Wynikowe zdarzenia SAXowe muszą "przejsc" przez procesor FOPa
         Result res = new SAXResult(fop.getDefaultHandler());
     
         // Uruchomienie transformaty i procesowania
         transformer.transform(src, res);

  10. Wynik przekształceń w postaci dokumentu PDF zostanie zapisany do przygotowanego wcześniej strumienia. Po zakończeniu tego procesu, można z FOPa wyciągnąć różne informacje o wygenerowanym dokumencie, np. liczbę wynikowych stron:

         FormattingResults foResults = fop.getResults();
         int generatedPages = foResults.getPageCount();