piątek, 12 sierpnia 2011

Wygeneruj sobie PDFa - Facelets (JSF) + CSS3 + Flying Saucer

Dzisiejszy post będzie nietypowy: dużo kodu i kilka speszyl-haków:)

Pokażę jak szybko i raczej bezboleśnie generować PDFy na podstawie HTMLa + CSS3 z wykorzystaniem szablonów w wydaniu Facelets.

Założenia

Oczywiście do generowania PDF możemy podejść na wiele sposobów. Możemy np. użyć natywnego api lub nawet zastosować Open Office postawionego w trybie servera (bez GUI) i korzystać ze wszelkich narzędzi do tworzenia dokumentu, aby na końcu "zapisać jako" pdf.

Natomiast w tym poście zakładam, że chcemy rozwiązać sprawę najprościej jak się da, bez potrzeby studiowania autystycznej dokumentacji.

Generujemy PDF

Na początek potrzebujemy biblioteki: http://code.google.com/p/flying-saucer (w paczce mamy ITexta i xercesa). Flying saucer to wspaniałe narzędzie, które przykrywa iTexta wygodnym api: na wejściu podajemy urla do strony, a na strumieniu wyjściowym dostajemy PDFa. Strona może odwoływać się do CSS3, w którym definiujemy nagłówki, stopki, numerowanie stron, podział stron. Wszystko zostanie kolejno opisane w dalszej części.

Pdfy będziemy zwracać z następującego servletu:
@SuppressWarnings("serial")
@WebServlet("/programy_szkolen_pdf/*")
public class PdfServlet extends HttpServlet {
	
	private static final String FONTS_PACKAGE = "fonts";
	private static final String FONTS_DIR = "/" + FONTS_PACKAGE + "/";
	
	protected void doGet(HttpServletRequest request,
			HttpServletResponse response) throws ServletException, IOException {				
		
		response.setContentType("application/pdf");
		OutputStream os = response.getOutputStream();
		
		String url = getUrl(request);

		try {
			ITextRenderer renderer = initRenderer();
			
			renderer.setDocument(url);
			renderer.layout();
			
			renderer.createPDF(os);
		} catch (Exception ex){
			throw new ServletException(ex);
		}
		
		response.flushBuffer();

	}

	private String getUrl(HttpServletRequest request) {
		String pdfServletPath = request.getServletPath();
		
		String cp = request.getContextPath();
		String program = request.getRequestURI();
		
		program = program.replace(cp + pdfServletPath, "");
		program = program.substring(0, program.length() - 4); // cut .pdf
				
		String url = "http://localhost:8080" + cp + program + ".xhtm?pdf=true";
		
		return url;
	}
}

Jak widać API latającego spodka jest proste:
- posługujemy się obiektem rendererm który posiada stan
- ustawiamy mu URL do strony, którą chcemy skonwertować na PDF
- wołamy metodę układającą pdf
- wynik kierujemy do strumienia odpowiedzi servletu.

I to generalnie byłoby wszystko:)

Zostało kilka szczegółów...
Metoda getUrl w moim przypadku robi pewne oszustwo operując na ścieżkach

Użytkownik woła servlet:
/programy_szkolen_pdf/szkolenie-java-start.pdf
btw: zauważcie jak zamapowany jest servlet: @WebServlet("/programy_szkolen_pdf/*")

A servlet wskazuje latającemu spodkowi adres:
http://localhost:8080/strona/szkolenie-java-start.xhtm?pdf=true

Zakładam, że zasoby do renderowania znajdują się na tej samej maszynie, stąd localhost.
Do czego służy parametr pdf dowiemy się za chwilę:)


Mamy jeszcze nieco smutnych szczegółów technicznych w naszym latającym spodku.

Renderera trzeba zainicjować - metoda initRenderer.

Jeżeli w PDF chcemy używać polskich (lub jakichkolwiek innych barbarzyńskich znaków), to musimy zarejestrować w rendereze fonty, których używamy w CSS (Arial, Verdana, cokolwiek). Służy do tego metoda renderer.getFontResolver().addFont
Ja napisałem prosty automat, metodę findFonts(), który automatycznie wyszukuje plików z fontami w *pakiecie* zdefiniowanym w stałej FONTS_PACKAGE. Chodzi o to aby uniknąć konfigurowania ścieżek. Po prostu skanujemy pakiet z class path.
Zatem do odpowiedniego pakietu należy sobie wkopiować (np z systemu) fonty. Oczywiście katalog powinien być niedostępny z sieci aby nie być posądzonym o dystrybucję komercyjnych fontów - jeżeli takowych używacie.

	
	private ITextRenderer initRenderer() throws IOException, DocumentException{
		List fonts = findFonts();
		
		ITextRenderer renderer = new ITextRenderer();
		
		for (String font : fonts) {
			renderer.getFontResolver().addFont(FONTS_DIR + font,
					BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
		}			
		
		return renderer;
	}

	private List findFonts() throws IOException {
		Enumeration resources = getClass().getClassLoader().getResources(FONTS_PACKAGE);

		URL resource = resources.nextElement();
		File directory = new File(resource.getFile());

		List fonts = new ArrayList();
		if (!directory.exists()) {
			return fonts;
		}
		File[] files = directory.listFiles();
		for (File file : files) {			
			fonts.add(file.getName());
		}
		return fonts;
	}


Szablony

Dlaczego Facelets i JSF 2?

Do tej pory różnego rodzaju strony tworzyłem w PHP z wykorzystaniem Smarty. Po kilku doświadczeniach uznałem to za... powiedzmy niewygodne.

Chcę być dobrze zrozumiany - Smarty są ok dla pewnej klasy problemów: np wówczas gdy mamy kontent w bazie danych (jakiś CMS), pobieramy kontent jako wiersze i rozsmarowujemy po po "dziurkach" w szablonie. Wówczas imperatywne API Smarty (w postaci obiektu, na którym wołamy metody definiujące zmienne szablonu) jest jak najbardziej ok.

Natomiast ja w klasach problemów typu strona firmowa, preferuję przechowywać kontent (strzępy konwentu) w plikach XHTML. W takim wypadku praca z imperatywnym API to po prostu pomyłka. Jako "weteran" aplikacji webowych czuję się jak ryba w wodzie poruszając się w kodzie. Baza i CMS jedynie mnie ogranicza.

Dlatego tworząc nową wersję strony formowej zdecydowałem się na coś... delikatnie mówiąc... zastawiającego. Użycie JSF - standardu prezentacji enterprise (która ma swoich fanów jak i anty-fantów) do stworzenia strony www :O

Okazało się, że jeżeli nie używać formularzy (pomijamy większość faz cyklu obsługi żądania), nie używać ManagedBeanów, a jedynie składania kawałków XHTMla to okazuje się, bo jest to bardzo produktywne narzędzie.

Przykład:
strona http://bottega.com.pl/szkolenie-jsf-2
wygląda jak każdy widzi.

Jest wyraźnie podzielona na kontent merytoryczny i ozdobniki w postaci nagłówka, stopki i paneli bocznych.

A tak wygląda jej kod:

	
	
		
			
	

	Java Enterprise Edition

	JSF 2
	
		Java Server Faces 2

	Projektanci, programiści

	2 dni

	50% wykłady / 50% warsztaty

			
	
		

Program został skontrowany tak aby przedstawić zagadnienia w kontekście konkretnych problemów, które ilustrują praktyczne wykorzystanie każdej z funkcjonalności JSF. Program szkolenia zawiera rozszerzenie o najlepsze praktyki projektowe i architektoniczne. Tematyka szkolenia obejmuje wszystkie cechy frameworka JSF 2.0 istotne z punktu widzenia developera aplikacji.


Znaczniki ui:definie definiują wartości zmiennych, które będą używane w szablonie.
Wartości to czysty kod XHTML, który jest na bieżąco validowany przez edytor.

Stronę możemy zawołać na 3 sposoby:
- podając jej url: http://bottega.com.pl/szkolenie-jsf-2
- renderując ją do pdf: http://bottega.com.pl/programy_szkolen_pdf/szkolenie-jsf-2.pdf (wówczas nasz servlet przekaże adres http://bottega.com.pl/szkolenie-jsf-2.xhtm?pdf=true) do Flying Saucera
- zagnieżdżając w pływającej ramce na facebooku podając adres http://bottega.com.pl/szkolenie-jsf-2.xhtm?fb=true

W każdym z tych przypadków oczekujemy innego wyglądu.

Zatem znacznik ui:composition wskazuje szablon: template="/layout/training_template.xhtml"

Plik ten układa w odpowiednich miejscach parametry szkolenia. Szablon ten odpowiada jedynie za fragment strony (układ info o szkoleniu), ale nie zawiera np. nagłówka, stopki, ani bocznych boxów.



		Szkolenie: 
		

	
		

Informacje ogólne


Jak widać, szablon ten definiuje 2 zmienne: title i body. Wartości tych zmiennych zawierają w sobie XHTMLa oraz wartości zmiennych z dekorowane pliku - znacznik ui:insert.

Warunkowe renderowanie

Pomarańczowy przycisk "Zapytaj o szkolenie" jest niepożądany w przypadku wersji PDF i facebook, dlatego został warunkowo renderowany, przy pomocy panelGroup i atrybutu rendered:



Bean UrlChecker będzie opisany już za chwilę.


Wybór szablonu docelowego

Szablon szkolenia, nie jest szablonem "ostatecznym". Jest on dekorowane znowuż przez szablon wybierający szablon główny: template="/layout/dispatching_template.xhtml"

Jest to warstwa pośrednia pozwalająca w jednym miejscu przechowywać kod, który potencjalnie ulegnie zmianie. Tak oto wygląda dispatching_template.xhtml:


	


Jak widać nie dodaje żądnej wizualizacji a jedynie deleguje dekorowanie do kolejnego szablonu. Z tym, że nie mamy tutaj konkretnej ścieżki a jedynie wyrażenie wyliczane w Javie (tak w normalnym języku programowania a nie w jakimś pokracznym języku opartym o znaczniki):

@ManagedBean
public class UrlChecker {

	public String getTemplateName(){
		if (isPdf())
			return "template_pdf";
		
		if (isFacebook())
			return "template_facebook";
		
		return "template";
	}
	
	public boolean isStandard(){
		return ! (isPdf() || isFacebook());
	}
	
	public boolean isPdf(){		
		return "true".equals(getParameter("pdf"));
	}
	
	public boolean isFacebook(){		
		return "true".equals(getParameter("fb"));
	}
	
	private String getParameter(String name){
		ExternalContext externalContext = FacesContext.getCurrentInstance().getExternalContext();				
		return externalContext.getRequestParameterMap().get(name);		
	}
	
	
}

Zatem jeżeli zawołamy naszą stronę w ten sposób:
http://bottega.com.pl/szkolenie-jsf-2.xhtm
to w wyniku otrzymamy zawartość dekorowaną szablonem template_pl, który zawiera banner, stopę i boczne boxy

jeżeli natomiast w ten sposób:
http://bottega.com.pl/szkolenie-jsf-2.xhtm?pdf=true
to szablon dekorujący template_pdf_pl doda nagłówek i stopkę dla dokumnetu

a takie wywołanie
http://bottega.com.pl/szkolenie-jsf-2.xhtm?fb=true skutkuje zastosowaniem niemal niewidocznego szablonu dla facebooka.


Podsumowanie dekorowania


Mamy zatem następującą hierarchię treści:

szablon: szablon www | szablon pdf | szablon facebook
szablon: dispatcher
szablon: training
strona: konkretne szkolenie

Czyli na najwyższym poziomie mamy trzy perspektywy (szablony) spojrzenia na dane merytoryczne zawarte w plikach opisujących szkolenie.

Inne strony nie będące programem szkoleń po prostu pomijają szablon training_template i są dekorowane wprost szablonem dispatcher template


Układ dokumentu PDF w HTML i CSS3

Składanie stron w HTML jest dosyć łatwe. Wręcz banalne w porównaniu z natywnym API lub autyzmem Open Office.

Szablon pdf to zwykły XHTML:
<head>


</head>

<body>
  
!!! TUTAJ trzy warstwy opisane poniżej !!!
<</body>

Style po komentarzy "Specific Elements" są *kluczowe*.


Wewnątrz pierwszej warstwy mamy kolejno kolejno:

nagłówek:


stopkę:


zawartość:

Program szkolenia




Na zakończenie bonus dla wytrwałych

Jak wołać w JSF 2 metody z parametrem? Zapewniam, że może się przydać w bardziej wyrafinowanych szablonach:)

Tak robimy to w Seam od 5 lat:




Natomiast w czystym JSF trzeba wykonać kilka prostych kroków aby uzyskać możliwość stosowania powyższej składni:
http://ocpsoft.com/java/jsf2-java/jsf2-how-to-add-the-magic-of-el-el2-to-jsf/

//====================================

Mam nadzieję, że udało mi się pokazać, że w Javie można efektywnie a przy tym czysto i elegancko zbudować stronę.

Przy innej okazji pokażę jak Filtry Servletów w dosłownie kilku linijkach biją na głowę autystyczne htaccess:)

A Smarki?... Już nigdy więcej:)

16 komentarzy:

Łukasz "Smok" Rybka pisze...

Rewelacyjny post! :) Przez dłuższy czas korzystałem z iText w wersji darmowej i zarówno jego API, jego zachowanie jak i dokumentacja pozostawiają wiele do życzenia. Sposób zaprezentowany przez Ciebie jest znacznie efektywniejszy i "czystszy" niż ten, z którego musiałem ja korzystać. Wielkie dzięki i oby więcej takich wpisów!

Krzysiek pisze...

Czysty JSF2 na takim Glassfish 3.1 wspiera wywołania metod out-of-the-box o ile pamiętam, problem jest jak się ma starą implementację EL np. Tomcat 6, ale jak się ma Tomcata to są same problemy ;)

Naprawdę lubię Facelets jeśli chodzi o tworzenie szablonów. Mimo nadmiaru namespaces, cyklu życia JSF itp, to jednak wspiera styl dekorator(jak Sitemesh) i kompozyt (jak Tiles), co komu lepiej leży :)

Sławek Sobótka pisze...

Tak, facelets to jedna z niewielu rzeczy, która dosyć nieźle wyszła słonecznym chłopcom;)
Używa się tego bezboleśnie i przyjemnie - raczej bez speszyl haków itp. Brakuje paru rzeczy, ale zawsze można się wspomóc kodzikiem z managed beanów:)

No ale być może wynika to z genezy: efekt przemyśleń id doświadczeń community, którego dodatkowo nie spartolono podczas standaryzacji:)

Krzysiek pisze...

Nie przyglądałem się dokładnie ale jest CMS w Java, który obsługuje Facelets jako dynamiczne szablony ;)

http://www.flexive.org/products/cms.html

Sławek Sobótka pisze...

lol
CMS na EJB:)
mają rozmach s...y;)

A co do dekoratora i kompozytu jeszcze to przez noc ułożyła mi się myśl, że chyba właśnie ta swoboda powoduje, że przyjemnie się używa faceletów.
Chociaż mogli pójść nieco dalej - tak jak jest to w Sitemesh - i pozwolić "nakładać" dekorator niejako bez wiedzy strony dekorowanej. Czyli wynieść definicję dekoratora gdzieś na zewnątrz pliku dekorowanego. No ale dobrze, że przynajmniej można "wyliczać" dekorator w runtime tak jak pokazałem w przykładzie z wyrażeniem jako wartość atrybutu template.

Krzysiek pisze...

Nie przyglądałem się wnętrznością Facelets, ale skoro to tworzy drzewo komponentów, to pewnie dałoby się wbić gdzieś w ten proces. Potem coś w stylu

dekoratorPdf(@Observes SelectTemplateForPage event){
if(isPdf(event.getRequest())){
event.overrideTemplate("/templates/pdf.xhtml")
}

powinno być wykonalne, kwestia szczegółów, co powinno trafić do metody ;)

emstol pisze...

Sam ostatnio myślałem jak tu wygenerować bezboleśnie PDF'a. Ostatecznie użyłem programu iReports. Teraz go trochę pochawalę (ale nie - nikt mi za to nie płaci). Pozwala on wyklikać layout, ustawić czcionki, połamać strony, wstawić obrazki, itp gdzie zamiast prawdziwych danych używa się ${model.parametrów}. Sam program jest naprawdę przyjemny w użyciu. Layout zapisuje się jako plik xml. Do projektu dodaje się kilka JARów (JasperReports) a w samym kodzie, można ze wszystkim wyrobić się w czterech linijkach:

//import
import net.sf.jasperreports.engine.*

//wczytujemy layout
JasperReport jasperReport = JasperCompileManager.compileReport(new FileInputStream("/path/to/layout.jrxml"));

//uzupelniamy go danymi
JasperPrint jasperPrint = JasperFillManager.fillReport(jasperReport, some_model /* Map */, new JREmptyDataSource())

//renderujemy do ... powiedzmy pliku pdf
JasperExportManager.exportReportToPdfStream(jasperPrint, new FileOutputStream("/path/to/file.pdf"))

I to wszystko!

Sławek Sobótka pisze...

iReport to już kombajn...
a przynajmniej snopo-wiązałka (że tak nawiążę do letniego klimatu;)

Flying Saucer ma tą zaletę, że ten sam kontent mogę serwować jako html lub pdf.

Do tego magik od html i css ma pole do popisu jeżeli chodzi o design pdfa.

btw: nie pisałem o tym, że gdy w moim podejściu wywołacie w przeglądarce:
strona?pdf=true
to widzimy htmla takiego jak będzie wchodził do latającego spodka. Teraz przy pewnej dozie wyobraźni można sobie "debugować" układ firebugiem.

fdreger pisze...

Jest taki kawałek w artkule: Jest to bezpieczne ze względu na wątki, ponieważ servlet podczas pracy (produkowanie PDFa) nie będzie współdzielony pomiędzy wątki obsługujące żądania.

Hmmmm. Dlaczego nie będzie podczas pracy współdzielony między wątki produkujące żądania?

Sławek Sobótka pisze...

Słuszna uwaga - to tylko specyfika pewnego środowiska. W ogólności nie musi tak być i jest to mylące bo niezgodne ze specyfikacją. Zmieniłem.

Krzysiek pisze...

A które środowisko pozwala na stanowe servlety? Te które znam korzystają tylko z jednej instancji domyślnie.

Sławek Sobótka pisze...

W tym przypadku nie chodziło o servlety stanowe w sensie sesji a raczej o multitona (wiele instancji, z których każda ma swoje prywatnego, zainicjowanego Flying Saucera) plus gwarancja, że konkrtna instancja Servletu będzie używana w jednym wątku w danym momencie - API FS jest wciąż stanowe podczas renderowania.

greymonkey pisze...

Sławek,
Zamawiam następny wpis...
Proszę DDD to stymuluje mój móżdżek;)

Pozdrawiam

Sławek Sobótka pisze...

@greymonkey
Specjalnie dla Ciebie
i innych czytelników, którzy czytają komentarze:

Nad tym pracujemy od kilku miesięcy:
http://code.google.com/p/ddd-cqrs-sample/
Kilkadziesiąt stron A4 wiki - dlatego posty na blogu zostały nieco zaniedbane ostatnimi czasy.

Jest to wersja nieoficjalna milestonea 1. Nieoficjalna, bo wiki ma jeszcze sporo błędów gramatycznych:)

sreb pisze...

Pożyteczny post, dzięki! Akurat w kręgu ostatnich zainteresowań. Używałam bibliotek fop do tej pory, no ale wynikało to raczej z rodzaju problemu (źródłem był xml, "wystarczy" napisać transformatę). Ale wygląda na to że to podejście też się przyda.

Łukasz Lenart pisze...

Polecam DynamicReports http://dynamicreports.sourceforge.net/ - używamy od niedawna w projekcie i same ochy i achy - wszystko tworzy się w kodzie za pomcą fluent interfejsu - naprawdę przyjemnie się koduje :-)