When we are writing a web application, we often face a requirement which states that our application must provide reporting to its users.
Typically the users of our application want to see these reports on the user interface and have the possibility to export them as Excel and/or PDF documents.
The problem is that creating PDF documents is not exactly a walk in the park. There are libraries that can create PDF documents from HTML markup, but I have never been completely happy with them. However, I had to tolerate their shortcomings because I didn’t have a choice. Then I heard about a command line tool called wkhtmltopdf and never looked back.
This blog post describes how we can create a microservice that transforms HTML documents into PDF Documents by using Java 8, Spring Boot, and Wkhtmltopdf.
Before we will implement our microservice, we will take a quick look at the PDF creation process. It has three steps:
- A client sends an HTTP request to our microservice, and specifies the url of the HTML document and the file name of the created PDF file.
- Our microservice invokes the wkhtmltopdf command line tool which reads the HTML document and transforms it into a PDF document.
- Our microservice reads the created PDF document and writes it to the body of the HTTP response.
Let’s start by installing wkhtmltopdf.
Installing Wkhtmltopdf
The first thing that we have to do is to install the wkhtmltopdf command line tool. We can simply download the installation packages from its website and install them.
If you are using OS X, I recommend that you install wkhtmltopdf by using Homebrew. You can do this by running the following command at the command prompt:
brew install Caskroom/cask/wkhtmltopdf
After we have installed the wkhtmltopdf command line tool, we can implement our microservice. Let’s start by implementing the component that transforms HTML documents into PDF documents.
Creating PDF Documents From HTML Documents
Before we can implement the component that transforms HTML documents into PDF documents and writes the created PDF documents to the body of the HTTP response, we have to create a class that is used to pass the required configuration parameters to that component.
We can do that by creating a PdfFileRequest class that has two fields:
- The fileName field contains the file name of the created PDF document.
- The sourceHtmlUrl field contains the URL address of the converted HTML document.
The source code of the PdfFileRequest class looks as follows:
1public class PdfFileRequest {
2
3 private String fileName;
4 private String sourceHtmlUrl;
5
6 PdfFileRequest() {}
7
8 public String getFileName() {
9 return fileName;
10 }
11
12 public String getSourceHtmlUrl() {
13 return sourceHtmlUrl;
14 }
15
16 public void setFileName(String fileName) {
17 this.fileName = fileName;
18 }
19
20 public void setSourceHtmlUrl(String sourceHtmlUrl) {
21 this.sourceHtmlUrl = sourceHtmlUrl;
22 }
23}
We can now create the component that creates PDF documents by following these steps:
- Create a PdfFileCreator class and annotate the created class with the @Service annotation.
- Add a static final Logger field to the created class. We will use this logger to write an error message to the log if the PDF document cannot be created.
- Add a writePdfToResponse() method to the created class. This method takes two method parameters:
- The PdfFileRequest object that contains the configuration of the PDF creation process.
- The HttpServletResponse object in which the created PDF document is written.
- Implement the writePdfToResponse() method by following these steps:
- Ensure that the file name of created PDF document and the url of the HTML document are valid.
- Create the command that is used to invoke the wkhtmltopdf command line tool. This command has three parts: 1. The name of the invoked program (wkhtmltopdf) 2. The url of the HTML document. 3. The output file. The string ‘-’ tells to wkhtmltopdf that it must write the created PDF file to STDOUT.
- Start the wkhtmltopdf process.
- Read the created PDF document from STDOUT and write it to the body of the HTTP response.
- Wait that the wkhtmltopdf process exits before continuing the current thread.
- Ensure that the PDF file was created successfully.
- Add the required metadata (content type and the file name of the created PDF file) to the HTTP response.
- If the PDF document could not be created, read the error message from STDERR and write it to the log.
- Destroy the wkhtmltopdf process.
The source code of the PdfFileCreator class looks as follows:
1import org.apache.commons.io.IOUtils;
2import org.slf4j.Logger;
3import org.slf4j.LoggerFactory;
4import org.springframework.stereotype.Service;
5
6import javax.servlet.http.HttpServletResponse;
7import java.io.BufferedReader;
8import java.io.IOException;
9import java.io.InputStream;
10import java.io.InputStreamReader;
11import java.io.OutputStream;
12import java.io.StringWriter;
13import java.util.Arrays;
14import java.util.List;
15import java.util.concurrent.TimeUnit;
16
17@Service
18class PdfFileCreator {
19
20 private static final Logger LOGGER = LoggerFactory.getLogger(PdfFileCreator.class);
21
22 void writePdfToResponse(PdfFileRequest fileRequest, HttpServletResponse response) {
23 String pdfFileName = fileRequest.getFileName();
24 requireNotNull(pdfFileName, "The file name of the created PDF must be set");
25 requireNotEmpty(pdfFileName, "File name of the created PDF cannot be empty");
26
27 String sourceHtmlUrl = fileRequest.getSourceHtmlUrl();
28 requireNotNull(sourceHtmlUrl, "Source HTML url must be set");
29 requireNotEmpty(sourceHtmlUrl, "Source HTML url cannot be empty");
30
31 List<String> pdfCommand = Arrays.asList(
32 "wkhtmltopdf",
33 sourceHtmlUrl,
34 "-"
35 );
36
37 ProcessBuilder pb = new ProcessBuilder(pdfCommand);
38 Process pdfProcess;
39
40 try {
41 pdfProcess = pb.start();
42
43 try(InputStream in = pdfProcess.getInputStream()) {
44 writeCreatedPdfFileToResponse(in, response);
45 waitForProcessBeforeContinueCurrentThread(pdfProcess);
46 requireSuccessfulExitStatus(pdfProcess);
47 setResponseHeaders(response, fileRequest);
48 }
49 catch (Exception ex) {
50 writeErrorMessageToLog(ex, pdfProcess);
51 throw new RuntimeException("PDF generation failed");
52 }
53 finally {
54 pdfProcess.destroy();
55 }
56 }
57 catch (IOException ex) {
58 throw new RuntimeException("PDF generation failed");
59 }
60 }
61
62 private void requireNotNull(String value, String message) {
63 if (value == null) {
64 throw new IllegalArgumentException(message);
65 }
66 }
67
68 private void requireNotEmpty(String value, String message) {
69 if (value.isEmpty()) {
70 throw new IllegalArgumentException(message);
71 }
72 }
73
74 private void writeCreatedPdfFileToResponse(InputStream in, HttpServletResponse response) throws IOException {
75 OutputStream out = response.getOutputStream();
76 IOUtils.copy(in, out);
77 out.flush();
78 }
79
80 private void waitForProcessBeforeContinueCurrentThread(Process process) {
81 try {
82 process.waitFor(5, TimeUnit.SECONDS);
83 }
84 catch (InterruptedException ex) {
85 Thread.currentThread().interrupt();
86 }
87 }
88
89 private void requireSuccessfulExitStatus(Process process) {
90 if (process.exitValue() != 0) {
91 throw new RuntimeException("PDF generation failed");
92 }
93 }
94
95 private void setResponseHeaders(HttpServletResponse response, PdfFileRequest fileRequest) {
96 response.setContentType("application/pdf");
97 response.setHeader("Content-Disposition", "attachment; filename=\"" + fileRequest.getFileName() + "\"");
98 }
99
100 private void writeErrorMessageToLog(Exception ex, Process pdfProcess) throws IOException {
101 LOGGER.error("Could not create PDF because an exception was thrown: ", ex);
102 LOGGER.error("The exit value of PDF process is: {}", pdfProcess.exitValue());
103
104 String errorMessage = getErrorMessageFromProcess(pdfProcess);
105 LOGGER.error("PDF process ended with error message: {}", errorMessage);
106 }
107
108 private String getErrorMessageFromProcess(Process pdfProcess) {
109 try {
110 BufferedReader reader = new BufferedReader(new InputStreamReader(pdfProcess.getErrorStream()));
111 StringWriter writer = new StringWriter();
112
113 String line;
114 while ((line = reader.readLine()) != null) {
115 writer.append(line);
116 }
117
118 return writer.toString();
119 }
120 catch (IOException ex) {
121 LOGGER.error("Could not extract error message from process because an exception was thrown", ex);
122 return "";
123 }
124 }
125}
Additional Reading:
Our next step is to create the controller that provides the public REST API of our microservice.
Implementing the REST API
We can create the REST API of our microservice by following these steps:
- Create a PdfController class and annotate the created class with the @RestController
- Add a private PdfFileCreator field to created class and inject its value by using constructor injection.
- Add a createPdf() method to the controller class. This method has two method parameters:
- The PdfFileRequest object is read from the request body and it configures the PDF creation process.
- The HttpServletRequest object is the HTTP response in which the created PDF document is written.
- Configure the createPdf() method to handle POST requests which are send to the url: ‘/api/pdf’.
- Implement the createPdf() method by invoking the writePdfToResponse() method of the PdfFileCreator class.
The source code of the PdfController class looks as follows:
1import org.springframework.beans.factory.annotation.Autowired;
2import org.springframework.web.bind.annotation.RequestBody;
3import org.springframework.web.bind.annotation.RequestMapping;
4import org.springframework.web.bind.annotation.RequestMethod;
5import org.springframework.web.bind.annotation.RestController;
6
7import javax.servlet.http.HttpServletResponse;
8
9@RestController
10class PdfController {
11
12 private final PdfFileCreator pdfFileCreator;
13
14 @Autowired
15 PdfController(PdfFileCreator pdfFileCreator) {
16 this.pdfFileCreator = pdfFileCreator;
17 }
18
19 @RequestMapping(value = "/api/pdf", method = RequestMethod.POST)
20 void createPdf(@RequestBody PdfFileRequest fileRequest, HttpServletResponse response) {
21 pdfFileCreator.writePdfToResponse(fileRequest, response);
22 }
23}
We have now implemented our microservice that transforms HTML documents into PDF documents by using wkhtmltopdf command line tool. Let’s find out how we can use our new microservice.
Using Our Microservice
We can use our microservice by following these steps:
- Send a POST request to the url: ‘/api/pdf’.
- Configure the PDF creation process by using JSON that is send in the body of the request.
For example, if we want to transform the front page of google.com into a PDF document, we have to send a POST request to the url: ‘/api/pdf’ and write the following JSON document to the request body:
1{
2 "fileName": "google.pdf",
3 "sourceHtmlUrl": "http://www.google.com"
4}
Let’s implement a simple Spring MVC controller that transforms the front page of google.com into a PDF document by using our microservice. We can do this by following these steps:
- Create a GooglePdfController class and annotate it with the @Controller annotation.
- Add a final RestTemplate field to the created class and inject its value by using constructor injection.
- Add a createPdfFromGoogle() method to the created class and configure it to handle GET requests send to the url: ‘/pdf/google’. This method takes an HttpServletResponse object as a method parameter.
- Implement the createPdfFromGoogle() method by following these steps:
- Create a new PdfFileRequest object, and set the name of the created PDF file (google.pdf) and the url of the HTML document (http://www.google.com).
- Send a POST request to the url: ‘http://localhost:8080/api/pdf’ by invoking the postForObject() method of the RestTemplate class. Pass the following method parameters to this method: 1. The URL (http://localhost:8080/api/pdf). 2. The object that is written to the request body (The created PdfFileRequest object). 3. The type of the return value (byte[].class).
- Write the received byte array, which contains the created PDF document, to the body of the HTTP response.
- Set the content type of the response to: ‘application/json’.
- Set the file name of the created PDF document to the HTTP response by using the Content-Disposition header.
The source code of the GooglePdfController class looks as follows:
1import org.apache.tomcat.util.http.fileupload.IOUtils;
2import org.springframework.beans.factory.annotation.Autowired;
3import org.springframework.beans.factory.annotation.Value;
4import org.springframework.stereotype.Controller;
5import org.springframework.web.bind.annotation.RequestMapping;
6import org.springframework.web.bind.annotation.RequestMethod;
7import org.springframework.web.client.RestTemplate;
8
9import javax.servlet.http.HttpServletResponse;
10import java.io.ByteArrayInputStream;
11import java.io.IOException;
12import java.io.InputStream;
13import java.io.OutputStream;
14
15@Controller
16class GooglePdfController {
17
18 private final RestTemplate restTemplate;
19
20 @Autowired
21 GooglePdfController(RestTemplate restTemplate) {
22 this.restTemplate = restTemplate;
23 }
24
25 @RequestMapping(value = "/pdf/google", method = RequestMethod.GET)
26 void createPdfFromGoogle(HttpServletResponse response) {
27 PdfFileRequest fileRequest = new PdfFileRequest();
28 fileRequest.setFileName("google.pdf");
29 fileRequest.setSourceHtmlUrl("http://www.google.com");
30
31 byte[] pdfFile = restTemplate.postForObject("http://localhost:8080/api/pdf",
32 fileRequest,
33 byte[].class
34 );
35 writePdfFileToResponse(pdfFile, "google.pdf", response);
36 }
37
38 private void writePdfFileToResponse(byte[] pdfFile,
39 String fileName,
40 HttpServletResponse response) {
41 try (InputStream in = new ByteArrayInputStream(pdfFile)) {
42 OutputStream out = response.getOutputStream();
43 IOUtils.copy(in, out);
44 out.flush();
45
46 response.setContentType("application/pdf");
47 response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
48 }
49 catch (IOException ex) {
50 throw new RuntimeException("Error occurred when creating PDF file", ex);
51 }
52 }
53}
Additional Reading:
We can now send a GET request to the url: ‘/pdf/google’ and we will receive the front page of google.com as a PDF document.
It looks pretty good, but if we use this technique in a real web application, we have to take a few things into account. These things are:
- Wkhtmltopdf is not very fault tolerant. For example, if it cannot find an image (or other resource such as .js or .css file), it doesn’t create the PDF file. It simply fails and writes the error message to STDERR.
- The error messages of Wkhtmltopdf can be quite long and a bit messy. In other words, it is not always “easy” to figure out what is wrong.
- Even though Wkhtmltopdf is very good at transforming HTML documents into PDF documents, you might have to create separate reporting views that are used only for this purpose. Also, sometimes you have to render these reporting views on the server.
- The performance of this solution depends from the wkhtmltopdf. We can make it faster by following these rules:
- Remove images from our HTML documents because they slow down wkhtmltopdf.
- Avoid expensive CSS selectors.
- Avoid CSS styles which cause really bad performance problems (such as gradients, border-radius, and some other styles).
- Follow the advice given in this StackOverflow question.
Some of these drawbacks are quite irritating, but I still think that using Wkhtmltopdf is a good idea. Why? Well, it is the least bad option, and it has a lot of configuration parameters that the other options don’t have.
Let’s summarize what we learned from this blog post.
Summary
This blog post has taught us four things:
- We can invoke the wkhtmltopdf command line tool by using Java 8 and configure it to write the created PDF document to STDOUT.
- We learned how we can read the created PDF document from STDOUT and write it to HTTP response.
- We learned how we can create a microservice that allows us to customize the PDF creation process.
- Wkhtmltopdf is not a perfect tool. It has a few drawbacks, but it is still the least bad option.
P.S. You can get the example application of this blog post from Github.