Spis treści

Generowanie prostych dokumentów pdf w Javie

Rozpocznę dzisiejszy wpis od przypomnienia jednego z moich pierwszych mini-projektów w JavaScript/TypeScript: stories.

Jego zadaniem było wygenerowanie statycznej strony HTML zawierającej małe “książeczki z obrazkami”. Każda z książeczek powstaje na podstwie metadanych zawatych w w pliku TOML , którego przykład znajdziesz w podlinkowanym niżej wpisie.

Info
O projekcie możesz przeczytać we wpisie Premiera serii bajek dla dzieci, a same bajeczki są dostępne tutaj. /bajeczki.jpg

Nowy format

Pomyślałam sobie - przy okazji niedawnej zabawy bibilioteką itext - że mogłabym generować bajki nie tylko w formacie HTML, lecz również jako pliki .pdf. Dotychczas przygotowywałam pdf-y ręcznie, a teraz mogę zaoszczędzić czas

Usiadłam więc i zaczęłam kodować.

Dwa etapy

Pisanie kodu podzieliłam na dwa etapy:

  1. Parsowanie pliku TOML i budowanie modelu
  2. Renderowanie pliku .pdf na podstawie modelu

Parsowanie i budowa modelu

Model

Szumnie brzmiący model do dwie klasy:

  • BookPage reprezentująca stronę; każda strona posiada numer, ścieżkę do obrazka i tekst

  • Book reprezentuje książeczkę: będzie posiadać metadane oraz listę stron

    Obydwie klasy przywołam tu w całości:

1
2
public record BookPage(int number, String imagePath, String text) {
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public record Book(String title, String author, String titleImagePath, String footer, List<BookPage> pages) {
    public Book {
        pages = Optional.ofNullable(pages)
                .orElse(List.of())
                .stream().sorted(Comparator.comparing(BookPage::number))
                .toList();
    }

    public Book withEmptyPages() {
        return new Book(title, author, titleImagePath, footer, null);
    }
}

Biblioteka parsująca TOML

Do parsowania formatu TOML w javie użyłam biblioteki toml4j:

1
2
3
4
5
<dependency>
    <groupId>com.moandjiezana.toml</groupId>
    <artifactId>toml4j</artifactId>
    <version>0.7.2</version>
</dependency>

Główna metoda parsująca

Używam jej w klasie MyTomlParser: jeśli wszystko pójdzie dobrze, metoda Optional<Book> parse(FileReader fileReader) zwróci niepusty Optional zawierający Book:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public Optional<Book> parse(FileReader fileReader) {
        final var toml = new Toml();
        var res = toml.read(fileReader);
        return Optional.of(
                new Book(res.getString(TITLE), 
                         res.getString(AUTHOR),
                         res.getString(TITLE_IMAGE_PATH), 
                         res.getString(FOOTER), 
                         parsePages(res)));
    }

przy czym w konstruktorze rekordu Book wywołana jest metoda parsująca strony.

Parsowanie pojedynczych stron

Mój plik TOML ma taką strukturę, że każda strona książeczki zawiera złożoną strukturę z danymi strony. W API biblioteki toml4j nie znalazłam czystego sposobu budowania złożonych struktur danych, więc użyłam tego, co widać poniżej: iteruję po liście obiektów-map, z których to map dopiero żmudnie buduję obiekt BookPage (rzutując numer strony na Long, ścieżkę na String i obcinając białe znaki z początku i końca tekstu):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
private List<BookPage> parsePages(Toml toml) {
        var li = new ArrayList<BookPage>();
        var pagesObjs = Optional.ofNullable(toml.getList(PAGES)).orElse(List.of());
        for (Object pagesObj : pagesObjs) {
            if (pagesObj instanceof Map m) {
                li.add(new BookPage(
                        ((Long) m.get(NUMBER)).intValue(),
                        (String) m.get(IMAGE_PATH),
                        ((String) m.get(TEXT)).trim()));
            }
        }
        return li;
    }

Generowanie pdf

Do generowania .pdf używam klasy o oczywistej nazwie Generator, której główna metoda generate zawiera kod generujący:

  • stronę tytułową (createTitlePage)
  • a w pętli kolejne strony książeczki (generateNextPage)

Książeczki mają pionową orientację i format PAGE_SIZE=PageSize.A5:

Metoda główna

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
        var writer = new PdfWriter(filename);
        var pdf = new PdfDocument(writer);
        Document doc = new Document(pdf, PAGE_SIZE);
        
        createTitlePage(doc, book);

        for (BookPage page : book.pages()) {
            generateNextPage(doc, page);
        }
        
        pdf.close();

API niskopoziomowe

Choć PdfDocument posiada bogate API, jest ono jednak dość niskopoziomowe i dotyczy np.

  • dodawania metadanych
  • dołączania załączników
  • podpisywania dokumentów
  • generowania zdarzeń dodania/usunięcia strony
  • dostępu do readera i writera
  • tworzenia spisu treści

Nic z powyższych nie będzie mi potrzebne, dlatego używam klasy opakowującej (będącej jednocześnie fasadą, za którą schowana jest cała złożoność biblioteki itext): Document.

API wysokopoziomowe

Obiekt Document pozwala:

  • dodać znacznik końca strony (tj. wstawić nową stronę)
  • dodać obraz bądź element blokowy
  • ustawić wielkość marginesów
  • dodać własny renderer

/posts/generate_pdf/adding_to_doc.png

Elementy blokowe (czyli implementujące interfejs IBlockElement) to

  • akapit (Paragraph)
  • tabela (Table)
  • linia (LineSeparator)
  • div (Div)
  • lista (List)
  • komórka (Cell)

/posts/generate_pdf/iblockelement.png

Strona tytułowa

Mój pdf jest bardzo prosty i potrzebuję jedynie dodawania obrazków i akapitów z tekstem.

Na przykład, kod generujący stronę tytułową wykorzystuje klasy z biblioteki itext:

  • Paragraph który jest kontenerem zawierającym inne elementy
  • Text pozwalający ustawić rozmiar tekstu, jego kolor oraz czcionkę
  • Image reprezentujacy zasób graficzny oraz ImageDataFactory pozwalający na utworzenie zasobu obrazu z podanej ścieżki
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
        doc.add(new Paragraph(
                new Text(book.author())
                        .setFontSize(AUTHOR_FONT_SIZE)
                        .setFont(myBoldFont)));


        doc.add(new Paragraph(book.title())
                .setFontSize(TITLE_FONT_SIZE).setFont(myBoldFont)
                .setFontColor(new DeviceRgb(30, 50, 200))
                .setMarginBottom(TITLE_MARGIN_BOTTOM));


        try {
            doc.add(getImage(doc, book.titleImagePath()));
        } catch (Exception e) {
            logger.error(e.getMessage());
            e.printStackTrace();
        }

        addFooter(doc, Optional.empty());

gdzie getImage tworzy obiekt typu Image o szerokości równej efektywnej szerokości strony:

1
2
3
4
5
private Image getImage(Document doc, String imagePath) throws MalformedURLException {
        final var filename = pathResolver.apply(imagePath).normalize().toString();
        final var width = doc.getPageEffectiveArea(PAGE_SIZE).getWidth();
        return new Image(ImageDataFactory.create(filename)).setWidth(width);
    }

natomiast addFooter tworzy stopkę z nazwą serii i opcjonalnym nuerem strony; w tej metodzie używam klas z itext:

  • Table oraz
  • Cell

które są odpowiedzialne za magię równego renderowania elementów, dla których można ustawić wyrównanie, kolor tła itd. Tabela jest kolekcją wierszy i kolumn zawierających komórki; te z kolei są rysowane domyślnie z czarną krawędzią, której nie chcę, więc dość nieeleganko się jej pozbywam:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
private void addFooter(Document doc, Optional<String> text) {
        Table table = new Table(text.isPresent() ? 2 : 1);
        table.setWidth(UnitValue.createPercentValue(100));
        table.addCell(new Div().add(
                    new Paragraph(new Text(BOOK_SERIES_NAME)))
                    .setTextAlignment(TextAlignment.CENTER)
                    .setBackgroundColor(BLUE));
        text.ifPresent(t -> {
            table.addCell(new Div().add(
                    new Paragraph(new Text(t)))
                    .setTextAlignment(TextAlignment.CENTER)
                    .setBackgroundColor(YELLOW));
        });
        table.setBackgroundColor(BLUE);
        removeBorders(table);
        table.setMarginTop(FOOTER_MARGIN_TOP);

        doc.add(table);
    }

Rezultat

/posts/generate_pdf/doc_in_evince.png

Alternatywa

Myślę, że warto by było użyć do generowania .pdf biblioteki pdfjs - dzięki temu czytelinicy “książeczek” mieliby możliwość pobrania .pdf-a bezpośrednio z głównej strony.

Kilka słów o itext7

Biblioteka itext jest dość mocno skomercjalizowana: jej strona internetowa promuje zamknięte i płatne narzędzia oraz rozszerzenia (np. ditto - “data-driven, template-based pdf generator”).

Na licencji AGPL wystawione są poszczególne biblioteki niskopoziomowe:

  • kernel-x.y.z.jar: low-level functionality
  • io-x.y.z.jar: low-level functionality
  • layout-x.y.z.jar: high-level functionality
  • forms-x.y.z.jar: AcroForms
  • pdfa-x.y.z.jar: PDF/A-specific functionality
  • pdftest-x.y.z.jar: test helper classes
  • barcode-x.y.z.jar: use this if you want to create bar codes
  • hyph-x.y.z.jar: use this if you want text to be hyphenated
  • font-asian-x.y.z.jar: use this is you need CJK functionality (Chinese / Japanese / Korean)
  • sign-x.y.z.jar: use this if you need support for digital signatures
  • styled-xml-parser-x.y.z.jar: use this if you need support for SVG or html2pdf
  • svg-x.y.z.jar: SVG support
  • commons-x.y.z.jar: commons module

Dokmentacja do itext to przede wszystkim:

Podsumowanie

Generowanie prostych dokumentów jest proste. Przykłady na stronie internetowej wystarczą, abby zacząć. Nic złożonego nie generowałam, nie mam więc wystarczającego doświadczenia, aby ocenić, jak bardzo biblioteka itext pomaga/przeszkadza w złożonych scenariuszach (skomplikowany układ, formularze, hasła etc.)

Przy pomocy dobrego IDE, po podpięciu źródeł (a w tym niezłej dokumentacji w kodzie) można dość szybko zorientować się w odpowiedzialności poszczególnych klas i modułów (kod itext7 wydaje mi się solidnie napisany, abstrakcje są ładnie wyodrębnione w postaci interfesów i opisane w JavaDoc).

W świecie Javy jest to chyba najbardziej popularna (i przyjazna programiście) biblioteka do generowania plików .pdf. Możliwe, że w przyszłości jeszcze nie raz po nią sięgnę.

GitHub

Projekt bajki dostępny jest na GitHubie.