Creating PDF Reports with iText 7 in Java
I have been using React-pdf for PDF report generation for quite sometime in one of the React project. It is a nice library for certain size of reports, as content is prepared as React components and styling becomes way easier. However if the page content is generated dynamically and there is no certainty in number of pages it might cause serious problems in the frontend side. I ended up getting my browser frozen or memory limit exceed errors. So for the huge reports I started looking for Java modules to handle it in the backend server, and it did not take me much to come across iText 7.
First thing caught my attention was html2pdf
module and I gave it a try. Html content can be provided as a string or a file.
public static void main(String[] args) throws FileNotFoundException {
String html = "<h1>Test</h1>" +
"<p>Hello World</p>";
String dest = "hello.pdf";
HtmlConverter.convertToPdf(html, new FileOutputStream(dest));
}
Resulting PDF:
If you have a certain design in Html or you would like to generate Html with thymeleaf, this module could be a good fit for your case.
In my scenario, each item is retrieved from database and inserted to the report, and its images are also placed. The length of the text in the fields and the number of images differs in each item. I decided to use iText core module and APIs to build up a report rather than generating html and converting to pdf. So let's have a look at the building blocks of core API.
Basic Components
We can add the dependency as follows:
implementation 'com.itextpdf:itext7-core:7.1.13'
PDF Document
PDF files are represented by PdfDocument
class and it has a wrapper called Document
. Instantiating PdfDocument
class can be done by providing the PdfReader
, or PdfWriter
in the constructor. As we intent to write to file we can instantiate with a PdfWriter
which wraps the OutputStream
or destination File
.
// Creating a PdfWriter
String dest = "example.pdf";
PdfWriter writer = new PdfWriter(dest);
// Creating a PdfDocument
PdfDocument pdfDoc = new PdfDocument(writer);
// Adding a new page
pdfDoc.addNewPage();
// Creating a Document
Document document = new Document(pdfDoc);
// Closing the document
document.close();
System.out.println("PDF Created");
We are going to add the components that we want to insert to the Document
object by using the following method:
public Document add(IBlockElement element)
Paragraph
Paragraph
class is a container for textual information. It can be filled by sending string to constructor or added text by add
method. It has many useful elements related to the text or the paragraph view.
String content = "Lorem ipsum dolor sit amet...";
Paragraph paragraph = new Paragraph(content);
paragraph.setFontSize(14);
paragraph.setTextAlignment(TextAlignment.CENTER);
paragraph.setBorder(Border.NO_BORDER);
paragraph.setFirstLineIndent(20);
paragraph.setItalic();
paragraph.setBold();
paragraph.setBackgroundColor(new DeviceRgb(245, 245, 245));
paragraph.setMargin(10);
paragraph.setPaddingLeft(10);
paragraph.setPaddingRight(10);
paragraph.setWidth(1000);
paragraph.setHeight(100);
document.add(paragraph);
document.add(paragraph); // add second time
AreaBreak
AreaBreak
can be used when we would like to start some element at the beginning of the next page. So the remaining part of the current page will be left empty and following elements will start from the next page.
String content = "Lorem ipsum dolor sit amet...";
document.add(new Paragraph(content));
document.add(new AreaBreak());
document.add(new Paragraph("This text will be located in the next pagee"));
List
We can insert a List
as follows:
Paragraph paragraph = new Paragraph("Lorem ipsum dolor...");
document.add(paragraph);
List list = new List();
list.add("Java");
list.add("Go");
list.add("React");
list.add("Apache Kafka");
list.add("Jenkins");
list.add("Elastic Search");
document.add(list);
Table
Table
class can be instantiated with providing either number of columns or array of column length. We can insert Cell
objects to a table and a cell may contain any IBlockElement
object.
float [] pointColumnWidths = {150F, 150F, 150F, 150F};
Table table = new Table(pointColumnWidths);
// Table table = new Table(4); init by number of columns
table.addCell(new Cell().add(new Paragraph("Id")));
table.addCell(new Cell().add(new Paragraph("Name")));
table.addCell(new Cell().add(new Paragraph("Location")));
table.addCell(new Cell().add(new Paragraph("Date")));
table.addCell(new Cell().add(new Paragraph("1000")));
table.addCell(new Cell().add(new Paragraph("Item-1")));
table.addCell(new Cell().add(new Paragraph("Istanbul")));
table.addCell(new Cell().add(new Paragraph("01/12/2020")));
table.addCell(new Cell().add(new Paragraph("1005")));
table.addCell(new Cell().add(new Paragraph("Item-2")));
table.addCell(new Cell().add(new Paragraph("Warsaw")));
table.addCell(new Cell().add(new Paragraph("05/12/2020")));
Table
and Cell
classes contains many methods related to styling such as borders, alignments, background options, margin/padding settings etc.
Image
Image
class is used to insert an image to pdf file. It might be created by a local file, remote url or stream.
String imFile = "images/logo2.png";
ImageData data = ImageDataFactory.create(imFile);
Image image = new Image(data);
image.setPadding(20);
image.setMarginTop(20);
image.setWidth(200);
image.setMaxHeight(250);
image.setAutoScale(false);
document.add(image);
ImageDataFactory class may create an image instance from local path or remote URL. Image
class has many styling methods as other components.
Exporting Reports With REST APIs
As a solution to my scenario, first of all, I prepared some general styling for each report type so that I can use it for different entity types. I created an AbstractPdfDocument
in order to re-use the common functionality, for example having title and logo at the top of each page, or some common functionality as inserting remote images, or tables. I achieved placing logo and title at the top of each page by registering an EventHandler to the Document class. So the components are listed as follows:
ImageDownloader
As the images are going to be downloaded from remote URLs, I decided to create some ImageDownloader
service to download images with a thread pool in parallel.
public class ImageDownloader {
private static final Logger logger = LoggerFactory.getLogger(ImageDownloader.class);
private final int NUMBER_OF_THREADS = 12;
private final ExecutorService executorService = Executors.newFixedThreadPool(NUMBER_OF_THREADS);
public ConcurrentHashMap <String, Future <Image>> downloadImagesInParallel(
List <String> imageList, ConcurrentHashMap <String, Future <Image>> imageMap) {
if (imageList != null && imageList.size() > 0) {
imageList.forEach(image -> startDownloadImageTask(imageMap, image));
}
return imageMap;
}
private void startDownloadImageTask(ConcurrentHashMap <String, Future <Image>> imageMap, String image) {
Future <Image> imageFuture = executorService.submit(() -> getImageObject(image));
imageMap.put(image, imageFuture);
}
private Image getImageObject(String url) {
try {
return new Image(ImageDataFactory.create(url, false));
} catch (MalformedURLException e) {
logger.error("download image failed: {}", e.getMessage());
return null;
}
}
}
TableHeaderEventHandler
This event handler is used to add a table with logo and title at the top of each page and it is code is as follows:
class TableHeaderEventHandler implements IEventHandler {
private Table table;
private float tableHeight;
private Document doc;
public TableHeaderEventHandler(Document doc, String documentTitle) {
this.doc = doc;
// Calculate top margin to be sure that the table will fit the margin.
initTable(documentTitle);
TableRenderer renderer = (TableRenderer) table.createRendererSubTree();
renderer.setParent(new DocumentRenderer(doc));
// Simulate the positioning of the renderer to find out how much space the header table will occupy.
LayoutResult result = renderer.layout(new LayoutContext(new LayoutArea(0, PageSize.A4)));
tableHeight = result.getOccupiedArea().getBBox().getHeight();
// set top margin
float topMargin = 36 + getTableHeight();
doc.setMargins(topMargin, 36, 36, 36);
}
@Override
public void handleEvent(Event currentEvent) {
PdfDocumentEvent docEvent = (PdfDocumentEvent) currentEvent;
PdfDocument pdfDoc = docEvent.getDocument();
PdfPage page = docEvent.getPage();
PdfCanvas canvas = new PdfCanvas(page.newContentStreamBefore(), page.getResources(), pdfDoc);
PageSize pageSize = pdfDoc.getDefaultPageSize();
float coordX = pageSize.getX() + doc.getLeftMargin();
float coordY = pageSize.getTop() - doc.getTopMargin();
float width = pageSize.getWidth() - doc.getRightMargin() - doc.getLeftMargin();
float height = getTableHeight();
Rectangle rect = new Rectangle(coordX, coordY, width, height);
new Canvas(canvas, rect)
.add(table)
.close();
}
public float getTableHeight() {
return tableHeight;
}
private void initTable(String documentTitle) {
table = new Table(new float[]{320F, 200F});
table.useAllAvailableWidth();
Cell title = new Cell();
title.setBorder(Border.NO_BORDER);
Paragraph movement_report = new Paragraph(documentTitle).setFontSize(17);
title.add(movement_report);
table.addCell(title);
table.setMarginBottom(20);
ImageData data = ImageDataFactory.create("images/rsz_logo10.png");
Image img = new Image(data);
img.setWidth(200);
Cell logo = new Cell();
logo.setBorder(Border.NO_BORDER);
logo.add(img);
table.addCell(logo);
}
}
AbstractPdfDocument
AbstractPdfDocument
contains the common functionality as adding header to pages, creation and destruction of Document
object, and helper methods as inserting images, tables, titles etc.
public abstract class AbstractPdfDocument<T> {
protected final int MAX_IMAGE_NUM = 4;
protected final Color GRAY = new DeviceRgb(245, 245, 245);
protected final Color GRAY_LINE = new DeviceRgb(212, 212, 212);
protected final Color WHITE = new DeviceRgb(255, 255, 255);
ConcurrentHashMap <String, Future <Image>> imageMap = new ConcurrentHashMap<>();
private final ImageDownloader imageDownloader;
protected final String documentTitle;
AbstractPdfDocument(ImageDownloader imageDownloader, String documentTitle) {
this.imageDownloader = imageDownloader;
this.documentTitle = documentTitle;
}
public final byte[] generatePdf(List<T> data) {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
PdfDocument pdfDocument = new PdfDocument(new PdfWriter(outputStream));
Document document = new Document(pdfDocument);
TableHeaderEventHandler handler = new TableHeaderEventHandler(document, documentTitle);
pdfDocument.addEventHandler(PdfDocumentEvent.END_PAGE, handler);
writeData(document, data);
document.close();
return outputStream.toByteArray();
}
public void startDownloadingImages(List <String> imageList) {
if (imageList != null && imageList.size() > 0) {
imageDownloader.downloadImagesInParallel(imageList, imageMap);
}
}
protected void insertImageTable(Document document, List <String> imageList) {
Table imageTable = new Table(4);
imageList.forEach(image -> {
try {
Image img = imageMap.get(image).get();
imageTable.addCell(new Cell()
.setTextAlignment(TextAlignment.CENTER)
.setHorizontalAlignment(HorizontalAlignment.CENTER)
.setVerticalAlignment(VerticalAlignment.MIDDLE)
.setBorder(new SolidBorder(GRAY_LINE, 1))
.add(img.scaleToFit(114, 114))
.setPadding(7));
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
});
document.add(new Paragraph().setMarginTop(10));
document.add(imageTable);
}
protected Table createTable(TableFields tableFields) {
Table table = new Table(new float[]{220F, 300F});
AtomicInteger rowCounter = new AtomicInteger(0);
tableFields.fieldList.forEach(field ->
insertIfNotNull(field.displayName, field.value, table, rowCounter));
return table;
}
protected Paragraph getBlockTitle(String title) {
return new Paragraph(title)
.setFontSize(13)
.setBorderBottom(new SolidBorder(GRAY_LINE, 1))
.setMarginTop(35);
}
protected void insertIfNotNull(String displayName, Object value, Table table, AtomicInteger rowCounter) {
if (value != null) {
Color color = rowCounter
.getAndIncrement() % 2 == 0 ?
GRAY :
WHITE;
table.addCell(new Cell()
.setBorder(Border.NO_BORDER)
.setBackgroundColor(color)
.add(new Paragraph(displayName)));
table.addCell(new Cell()
.setBorder(Border.NO_BORDER)
.setBackgroundColor(color)
.add(new Paragraph(String.valueOf(value))));
}
}
protected static class TableField {
public String displayName;
public Object value;
protected TableField(String displayName, Object value) {
this.displayName = displayName;
this.value = value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof TableField)) return false;
TableField that = (TableField) o;
return Objects.equals(displayName, that.displayName) &&
Objects.equals(value, that.value);
}
@Override
public int hashCode() {
return Objects.hash(displayName, value);
}
}
protected static class TableFields {
private List <TableField> fieldList = new ArrayList <>();
protected void add(String displayName, Object value) {
fieldList.add(new TableField(displayName, value));
}
protected void add(TableField field) {
fieldList.add(field);
}
}
protected abstract void writeData(Document document, List<T> data);
}
This class uses Factory Method
design pattern with its writeData
abstract method. The inheritors of this class has to implement this method to lead the insertion of actual content. For each entity the content is inserted differently, and by implementing this method the concrete classes should define how it should be inserted. And also, child classes might use the helper methods for common tasks.
ItemPdfDocument
ItemPdfDocument
is the concrete class which extends the AbstractPdfDocument
and implements the writeData
method. It handles inserting item details and after uses helper method insertImageTable
of its parent class.
public class ItemPdfDocument extends AbstractPdfDocument<Item> {
ItemPdfDocument(ImageDownloader imageDownloader) {
super(imageDownloader, "Item Report");
}
@Override
protected void writeData(Document document, List <Item> items) {
startDownloadingItemImages(items);
items.forEach(item -> {
addItemFields(document, item);
addItemImages(document, item);
});
}
private void startDownloadingItemImages(List <Item> items) {
if(items != null && items.size() > 0) {
startDownloadingImages(
items.stream()
.map(Item::getImgList)
.flatMap(Collection::stream)
.collect(Collectors.toList())
);
}
}
private void addItemFields(Document document, Item item) {
document.add(getBlockTitle("Report: " + item.getId()).setMarginTop(0));
TableFields movementFields = new TableFields();
movementFields.add("Report Id", item.getId());
movementFields.add("Item Name", item.getName());
movementFields.add("Title", item.getTitle());
movementFields.add("Description", item.getDescription());
movementFields.add("Area", item.getArea());
movementFields.add("Location", item.getLocation());
Table movementDetails = createTable(movementFields);
document.add(movementDetails);
}
private void addItemImages(Document document, Item item) {
insertImageTable(document, item.getImgList());
document.add(new Paragraph());
}
}
So introducing new reports for each entity type is easy, we can create a new concrete class and show how to insert the content in the writeData
method.
An example report generated by this document looks like as follows:
Controller
At the controller I create an endpoint to retrieve the generated pdf file. At the first attempt I returned the byte array from output stream however I realized that might cause corrupted PDF files at the client side. So I encode it with base64 and client should decode it before saving the file.
@PostMapping("/pdf")
public void exportPdf(@RequestParam(required = false) List<Long> idList, HttpServletResponse response) throws IOException {
response.setHeader("Expires", "0");
response.setHeader("Cache-Control", "must-revalidate, post-check=0, pre-check=0");
response.setHeader("Pragma", "public");
response.setContentType("application/pdf");
byte[] byteArray = itemService.exportPdf(idList);
byte[] encodedBytes = Base64.getEncoder().encode(byteArray);
response.setContentLength(encodedBytes.length);
OutputStream os = response.getOutputStream();
os.write(encodedBytes);
os.flush();
os.close();
}