Konwersja HTML do PDF z IText 2

Autor
Damian
Terlecki
16 minut
Java

Zbiór otwartoźródłowych bibliotek napisanych w Javie konwertujących proste szablony HTML do postaci dokumentu HTML nie jest zbyt wielki. Liczba ta szczególnie maleje po odfiltrowaniu zależności na restrykcyjnych licencjach (A)GPL. Jedną z najpopularniejszych bibliotek w tym kontekście jest IText. W datowanej wersji 4.2.2 zmiana licencji z MPL na GPL dała motywację na powstanie alternatywy, jaką jest OpenPDF na mniej restrykcyjnej (do wykorzystania jako biblioteki) licencji LGPL.

Wciąż mimo sędziwego wieku znajdziemy jednak wykorzystanie starszej wersji IText 2.1.7, np. w popularnej bibliotece JasperReports. Również w projektach legacy często zdarzy Ci się mieć tę bibliotekę na liście zależności. Poniżej zobaczysz jak generować proste dokumenty PDF z postaci HTML oraz typowe problemy datowanej wersji IText.

IText 2.1.7 konwersja HTML do PDF

Dwa alternatywne źródła konwersji do dokumentu PDF to HTML4 obsługiwany przez klasę com.lowagie.text.html.simpleparser.HTMLWorker i XHTML obsługiwany przez com.lowagie.text.html.HtmlParser. Modelem bazowym dla obu parserów jest Document. Poprzez jego utworzenie możesz zdefiniować rozmiar dokumentu i standardowe marginesy.

Aby wygenerować plik PDF, wystarczy, że w pierwszym kroku dodamy namiary na docelowy plik przez fabrykę getInstance(Document, OutputStream) klasy PdfWriter. Następnie przepuszczamy szablon HTML przez jeden z parserów, powodując zapis przy zamknięciu dokumentu.

package dev.termian.itextdemo;

import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.PageSize;
import com.lowagie.text.html.HtmlParser;
import com.lowagie.text.html.simpleparser.HTMLWorker;
import com.lowagie.text.pdf.PdfWriter;

import java.awt.Desktop;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.Reader;
import java.io.StringReader;
import java.util.Objects;

public class Main {
    public static void main(String[] args) throws IOException, DocumentException {
        Document pdfDocument = new Document(PageSize.A4);
        File outputPdf = File.createTempFile("output-%s".formatted(String.valueOf(System.currentTimeMillis())), ".pdf");
        try (InputStream inputHtmlStream = Objects.requireNonNull(Thread.currentThread().getContextClassLoader().getResourceAsStream("input.html"));
             Reader inputHtmlReader = new InputStreamReader(inputHtmlStream);
             OutputStream outputPdfStream = new FileOutputStream(outputPdf)) {
            PdfWriter.getInstance(pdfDocument, outputPdfStream);
            pdfDocument.open();
            HTMLWorker htmlWorker = new HTMLWorker(pdfDocument);
            htmlWorker.parse(inputHtmlReader);
            pdfDocument.close();
        }
        Desktop.getDesktop().open(outputPdf);
    }
}

W powyższym przykładzie konwersję wykonuję na pliku input.html znajdującym się na ścieżce classpath (np. dodany jako zasób z src/main/resources/input.html). Do przekazania szablonu w postaci Stringu alternatywnie możesz wykorzystać java.io.StringReader. Konwersję w pamięci dopełnisz, podmieniając wyjściowy strumień plikowy na java.io.ByteArrayOutputStream.

Z kolei zamiast konwersji HTML4 poprzez HTMLWorker możesz wywołać konwersję XHTML przy pomocy HtmlParser.parse(pdfDocument, inputHtmlReader). Ten drugi parser da Ci większ możliwości stylizacji, o czym za chwilę.

CVE-2017-9096

Tej wersji IText dotyczy podatność CVE-2017-9096 należąca do kategorii błędnej konfiguracji OWASP. Przy standardowych ustawieniach, biblioteka jest podatna na atak typu XXE (XML External Entity) injection. Dla zobrazowania ataku załóżmy, że użytkownik może podać dowolny szablon XHTML, np.:

<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd" >]>
<html>
<body>
&xxe;
</body>
</html>

Po konwersji takiego szablonu w systemie Linux, wygenerowany dokument zawierał będzie dane pliku systemowego /etc/passwd:

Prezentacja ataku XXE przy użyciu IText 2 – dokument zawierający dane z pliku systemowego

Do zabezpieczenia przed tym atakiem sprawdzamy jakie interfejsy wykorzystywane są do parsowania XML. Odpowiedź znajdziemy w klasie HtmlParser dziedziczącej z XmlParser, jest to SAXParser. Posiłkując się ściągą OWASP podpowiadającą jak zbudować zabezpieczony parser, weryfikujemy wskazówki w połączeniu z wykorzystywaną implementacją SAXParserFactory.newInstance().getClass().getName().

Zamiast pośredniego parsowania przez HtmlParser, adaptujemy rozwiązanie do konwersji poprzez getSecureSAXParser().parse(inputHtmlStream, new SAXmyHtmlHandler(pdfDocument)). W rezultacie, dla tego ataku, oczekiwać będziemy wyrzucanie błędu, np.:

Exception in thread "main" org.xml.sax.SAXParseException; lineNumber: 2; columnNumber: 10; DOCTYPE is disallowed when the feature "http://apache.org/xml/features/disallow-doctype-decl" set to true.

Spacje w tabeli i java.lang.ClassCastException: com.lowagie.text.Table

Kolejnym problemem starej wersji IText jest jej czułość na spacje w szablonach XHTML. Objawia się to błędami np. przy generowaniu tabel zawierających te znaki jak w zgłoszeniu dla OpenPDF. Jeśli nie zamierzasz trzymać się odpowiedniego formatowania, spójrz na poprawkę do klasy SAXiTextHandler bazowej dla wcześniej wykorzystywanego SAXmyHtmlHandler.

Innym rozwiązaniem niemodyfikującym kodu biblioteki jest obejście tego zachowania poprzez ignorowanie pustych elementów. Taką logikę dodasz, nadpisując metody handleStartingTags i handleEndingTags wspomnianej klasy:

public class Main {
    public static void main(String[] args) throws Exception {
        //...
        getSecureSAXParser().parse(inputHtmlStream, new SAXmyHtmlHandler(pdfDocument) {
            @Override
            public void handleStartingTags(String name, Properties attributes) {
                if (currentChunk != null && currentChunk.getContent() != null && currentChunk.getContent().trim().isEmpty()) {
                    currentChunk = null;
                }
                super.handleStartingTags(name, attributes);
            }

            @Override
            public void handleEndingTags(String name) {
                if (currentChunk != null && currentChunk.getContent() != null && currentChunk.getContent().trim().isEmpty()) {
                    currentChunk = null;
                }
                super.handleEndingTags(name);
            }
        });
        //...
    }
}

Aktualizacja zależności BouncyCastle

BouncyCastle to biblioteki rozszerzające funkcjonalności kryptograficzne w Javie. IText używa ich między innymi do implementacji zabezpieczania dokumentów. Niestety starsze wersje BouncyCastle kilka wykrytych podatności.

Do zabezpieczenia się przed potencjalnymi atakami, polecam dystrybucję połatanej biblioteki od Jaspersoft (np. 2.1.7.js10 bądź nowszej). Jest ona wykorzystywana między innymi w bibliotece do generowania raportów JasperReports. Nie znajdziemy jej jednak w centralnym repozytorium, dlatego konieczna będzie konfiguracja mavenowa:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <!--...-->
    <repositories>
        <repository>
            <id>jaspersoft</id>
            <url>https://jaspersoft.jfrog.io/jaspersoft/third-party-ce-artifacts/</url>
            <releases/>
        </repository>
        <!--...-->
    </repositories>
    <dependencies>
        <dependency>
            <groupId>com.lowagie</groupId>
            <artifactId>itext</artifactId>
            <version>2.1.7.js10</version>
        </dependency>
        <!--...-->
    </dependencies>
    <!--...-->
</project>

Natomiast w przypadku standardowej dystrybucji, ścieżka aktualizacji jest dosyć skomplikowana, bo polega na wykluczeniu zależności tranzytywnych (nastąpiła zmiana groupId), dodaniu aktualnych bibliotek BouncyCastle i dostosowanie kodu kilku klas biblioteki. Alternatywnie warto zastanowić się, czy na pewno potrzebujemy funkcjonalność enkrypcji (i odczytywania) PDF i rozważyć ewentualne usunięcie zależności, bądź weryfikację czy podatności nas dotyczą.


<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <!--...-->
    <dependencies>
        <dependencies>
            <dependency>
                <groupId>com.lowagie</groupId>
                <artifactId>itext</artifactId>
                <version>2.1.7</version>
                <exclusions>
                    <exclusion>
                        <groupId>*</groupId>
                        <artifactId>*</artifactId>
                    </exclusion>
                </exclusions>
            </dependency>
        </dependencies>
        <!--...-->
    </dependencies>
    <!--...-->
</project>

Elementy i style

Zasadniczą niedogodnością obu parserów HTML jest brak specjalnej obsługi tagu <style></style>. Prowadzi to do wyrenderowania jego zawartości w postaci tekstu. W tej wersji nie zaznamy automatycznej konwersji styli, jesteśmy zdani na ograniczoną obsługę stylów wbudowanych. Taka funkcjonalność jest dostępna w nowszych wersjach już na licencji GPL. Z innych rozwiązań rzuć okiem na stylowanie przy użyciu CSS 2.1 implementowane przez Flying Saucer (LGPL) bazujące na OpenPDF.

Mniej istotną sprawą jest zbiór wspieranych elementów, który w przypadku obu parserów jest podzbiorem elementów HTML 4. W tym kontekście bardziej interesujące są możliwe do ustawienia atrybuty wpływające na ich wygląd i będące alternatywę stylowania.

HTMLWorker

HTMLWorker wspiera następujące elementy (tagi): ol, ul, li, a, pre, font, span, br, p, div, body, table, td, th, tr, i, b, u, sub, sup, em, strong, s, strike, h1, h2, h3, h4, h5, h6, img, hr.

Implementuje atrybuty:

  • align, size, before, after, encoding, face — elementy tekstowe;
  • width, height, src, image_pathimg;

Atrybut image_path – pozwala na załadowanie zdjęcia z pliku systemowego (wymagany pusty atrybut src="").

  • indentul, or;
  • widthtable, hr;
  • align, valign, border, cellpadding, bgcolor, colspan, extraparaspacetr, td.

Style: font-family, font-size, font-style, font-weight, text-decoration, color, line-height, text-align, padding-left.

Atrybut face, jak i styl font-family pozwalają na użycie jednej z 14 wbudowanych czcionek (Courier, Helvetica, Times, Symbol, ZapfDingbats), bądź wskazanie systemowej czcionki true type, np. style='font-family: "/System/Library/Fonts/Supplemental/Times New Roman.ttf"'. Do wylistowania zainstalowanych czcionek w systemie Unix, spróbuj użyć polecenia fc-list. Standardowe enkodowanie to Cp1252. Niestety, rozmiary czcionek są dosyć ograniczone dla parsera HTML4.

Mniejszym bądź większym nakładem pracy obsłużymy również własne atrybuty. Interesujące może być niestandardowa fabryka czcionek bądź fabryka obrazków (np. base64) ustawiane metodą setInterfaceProps.

SAXiTextHandler

SAXiTextHandler wspiera bardzo podobny zbiór elementów (tagów): ol, ul, li, a, code, font, span, br, p, div, html, table, td, th, tr, i, b, u, sup, sub, em, strong, s, h1, h2, h3, h4, h5, h6, var, img, hr, annotation, itext, chapter, section, chunk, newpage.

Zaimplementowane atrybuty są znacznie obszerniejsze, najważniejsze z nich to:

  • encoding, embedded, font, size, fontstyle, red, green, blue, color, leading, itext, generictag — elementy tekstowe;
  • pagesize, orientation, left, right, top, bottomitext;
  • itext, localgoto, remotegoto, page, destination, localdestination, subscript, generictag, backgroundcolorchunk;
  • name, hrefa;
  • numbered, lettered, lowercase, autoindent, alignindent, first, listsymbol, indentationright, indentationleft, symbolindentol, ul;
  • horizontalalign, verticalalign, width, colspan, rowspan, leading, header, nowraptd, th;
  • widths, columns, lastHeaderRow, align, cellspacing, cellpadding, offset, width, tablefitspage, cellsfitpage, convert2pdfp, borderwidth, left, right, top, bottom, red, green, blue, bordercolor, bgred, bggreen, bgblue, bgcolor, grayfilltable;
  • numberdepth, indent, indentationleft, indentationrightchapter, section;
  • src, align, underlying, textwrap, alt, absolutex, absolutey, plainwidth, plainheight, rotationimg;
  • llx, lly, urx, ury, title, content, url, named, file, destination, pageannotation.

Style: font-family, font-size, font-style, font-weight, color.

Tym parserem znacznie łatwiej jest zarządzać dokumentem i nowymi stronami, ostylować tabele i rozmieścić kolumny i komórki, a także tworzyć linki i adnotacje. Z drugiej strony brak specyfikacji skutkuje koniecznością przeglądania implementacji w celu zrozumienia użycia niestandardowych atrybutów.

Podsumowanie

Mając do dyspozycji bibliotekę IText w wersji 2.1.7 jesteśmy w stanie przekonwertować proste szablony HTML4 do postaci PDF. Tej wersji brakuje jednak implementacji styli kaskadowych. Wygenerowanie zadowalającego dokumentu jest utrudnione, szczególnie gdy korzystamy z prostszego parsera HTMLWorker.

Niewątpliwe wady możemy teoretycznie obejść, korzystając parsera szablonów XHTML SAXmyHtmlHandler, który po dodatkowym zabezpieczeniu, umożliwia stylowanie przy pomocy niestandardowych atrybutów. Jeśli jednak potrzebujesz stabilnej konwersji, w tym obsługi HTML5/CSS3 warto wziąć pod uwagę najnowszą wersję biblioteki, bądź poszukać alternatyw na zadowalającej licencji.