HTML to PDF in Java: Thymeleaf Templates to Finished PDFs
Blog / HTML to PDF
HTML to PDF

HTML to PDF in Java: Thymeleaf Templates to Finished PDFs

Demetra Theodorou · Oct 21, 2026

Render Thymeleaf templates and plain HTML to PDF from Java, with page setup, headers, JavaScript content and a Spring Boot controller. Every listing was compiled and run before publishing.

Java teams usually arrive at HTML to PDF with a Thymeleaf or JSP template in hand and a list of libraries that each handle part of the job. This tutorial takes a different route and keeps the JVM free of rendering engines. It goes from a single URL conversion through page setup, repeating headers, templates built from records, JavaScript-heavy pages and error handling, and ends with a Spring Boot controller that serves invoices.

Rendering happens inside the HTML to PDF API, where Chrome is already running, so the application only sends HTTP requests. Every listing here was compiled with Maven and executed against the API before publication, and the Spring Boot endpoints were exercised with curl while the application ran.

Key takeaways

  • Two calls on one client do the work: generatePdf renders, getFile returns the bytes. No Flying Saucer limits, no iText licence, no browser on the server.
  • Every request is a builder. Set pageSizeType explicitly, the default is Ledger, and pass margins as a PdfPageMargin.
  • When a page finishes drawing after load, let it insert a marker element and wait for that selector with waitForSelector.
  • In Spring Boot, keep one PdfGate client and return the bytes with the PDF media type, or hand back a pre-signed link.

The options in Java

Java has more HTML to PDF libraries than most languages, and they fall into three groups:

  • Pure Java renderers such as Flying Saucer and OpenHTMLtoPDF. No native dependencies, but they implement CSS 2.1 with a few extensions, so flexbox, grid and anything that needs JavaScript do not work. Fine for a static invoice, painful for a page built with a modern framework.
  • iText with pdfHTML. The most complete Java option, but AGPL. If your product is not open source, you need the commercial licence.
  • A real browser through Playwright for Java or Selenium. The output matches Chrome exactly, but every server now carries a browser of a few hundred megabytes and the memory to run it.
  • A conversion API. The JVM carries no rendering engine and no licence, and the result is exactly what Chrome prints. In exchange each document costs a network call and the service costs a subscription.

The sections below use the API. For a side-by-side with running a browser in your own infrastructure, read the Puppeteer comparison.

Add the Java SDK

The artifact com.pdfgate:pdfgate lives on Maven Central and runs on every LTS release from Java 8 to Java 25. OkHttp and Gson come with it transitively. The listings rely on text blocks and records, so compile them with Java 16 or newer, or swap in string concatenation and plain classes on older runtimes.

Gradle:

1implementation "com.pdfgate:pdfgate:1.3.0"

Maven:

1<dependency>2  <groupId>com.pdfgate</groupId>3  <artifactId>pdfgate</artifactId>4  <version>1.3.0</version>5</dependency>

Keys come from the dashboard, and their prefix selects the environment. test_ keys talk to the sandbox, which watermarks its output and is free, while live_ keys talk to production. The samples take the key from the PDFGATE_API_KEY environment variable.

Convert a URL to PDF

A render is generatePdf followed by getFile. The first returns a PdfGateDocument carrying the ID of the stored result, the second fetches that result as a byte array.

1import com.pdfgate.GeneratePdfParams;2import com.pdfgate.GeneratePdfParams.PageSizeType;3import com.pdfgate.GetFileParams;4import com.pdfgate.PdfGate;5import com.pdfgate.PdfGateDocument;6import java.nio.file.Files;7import java.nio.file.Path;89public class HtmlToPdf {10  public static void main(String[] args) throws Exception {11    PdfGate client = new PdfGate(System.getenv("PDFGATE_API_KEY"));1213    PdfGateDocument page = client.generatePdf(GeneratePdfParams.builder()14        .url("https://en.wikipedia.org/wiki/Portable_Document_Format")15        .pageSizeType(PageSizeType.A4)16        .build());1718    byte[] pdf = client.getFile(GetFileParams.builder().documentId(page.getId()).build());19    Files.write(Path.of("wikipedia.pdf"), pdf);20    System.out.println("URL -> " + page.getId() + " (" + page.getSize() + " bytes)");21  }22}
  • Each request type has a builder. Set only what you need and finish with build(). Null is never required for an unused option.
  • The renderer fetches the URL from PDFGate's network, so it has to be public. A localhost address fails fast with a 400.
  • Unless you choose a size, the output is Ledger. GeneratePdfParams.PageSizeType is a nested enum with A0 to A6, Letter, Legal, Ledger and Tabloid. Pixel dimensions go in width and height.
  • getFile returns byte[], so the 23 page, 1.4 MB Wikipedia article is held in memory at once. For very large documents, request a download link instead, as shown in the Spring Boot section.
  • Build one PdfGate and share it. It is safe across threads and keeps a connection pool.

Convert an HTML string

For documents your code produces, pass the markup to html and leave url unset. A text block holds a complete HTML file without escape noise, and a <style> element inside it travels with the content. Public stylesheets, images and fonts linked from the markup are loaded during the render.

1import com.pdfgate.GeneratePdfParams.PdfPageMargin;23String invoiceHtml = """4    <!DOCTYPE html>5    <html>6    <head>7      <meta charset="utf-8">8      <style>9        body { font-family: Helvetica, Arial, sans-serif; color: #1f2933; }10        h1 { color: #0055d4; }11        table { width: 100%; border-collapse: collapse; margin-top: 24px; }12        th { background: #0055d4; color: white; text-align: left; padding: 8px; }13        td { padding: 8px; border-bottom: 1px solid #e4e7eb; }14        .total { text-align: right; font-size: 18px; margin-top: 16px; }15      </style>16    </head>17    <body>18      <h1>Invoice #2026-0142</h1>19      <p>Billed to Acme GmbH, 21 October 2026</p>20      <table>21        <tr><th>Item</th><th>Qty</th><th>Price</th></tr>22        <tr><td>API plan, Business</td><td>1</td><td>EUR 79.00</td></tr>23        <tr><td>Extra documents</td><td>2,000</td><td>EUR 20.00</td></tr>24      </table>25      <p class="total">Total EUR 99.00</p>26    </body>27    </html>28    """;2930PdfGateDocument invoice = client.generatePdf(GeneratePdfParams.builder()31    .html(invoiceHtml)32    .pageSizeType(PageSizeType.A4)33    .margin(new PdfPageMargin("20mm", "20mm", "15mm", "15mm"))34    .printBackground(true)35    .build());3637Files.write(Path.of("invoice.pdf"),38    client.getFile(GetFileParams.builder().documentId(invoice.getId()).build()));39System.out.println("HTML -> " + invoice.getId() + " (" + invoice.getSize() + " bytes)");
  • PdfPageMargin has a four-argument constructor in the order top, bottom, left, right. Values are CSS lengths, and null keeps a side at its default.
  • Print mode drops backgrounds by default, so coloured table headers vanish unless printBackground(true) is set.
  • orientation(FileOrientation.LANDSCAPE) turns the page, and emulateMediaType(EmulateMediaType.SCREEN) renders with the screen stylesheet rather than the print one.
  • The closing """ of a text block decides how much indentation is stripped. Line it up with the opening tag to keep the HTML flush left.

The complete list of layout options is in page size, margins and orientation.

Headers, footers and page numbers

Page headers and footers are passed as their own HTML in header and footer and repeated on every page. Inside them, elements with the classes date, title, url, pageNumber and totalPages are populated by the renderer. The top and bottom margins must leave room, or the request is rejected.

1PdfGateDocument report = client.generatePdf(GeneratePdfParams.builder()2    .url("https://en.wikipedia.org/wiki/Portable_Document_Format")3    .pageSizeType(PageSizeType.A4)4    .header("""5        <div style="width: 100%; font-size: 10px; padding: 0 15mm; color: #52606d;">6          Portable Document Format, exported <span class="date"></span>7        </div>8        """)9    .footer("""10        <div style="width: 100%; font-size: 10px; padding: 0 15mm; text-align: right; color: #52606d;">11          Page <span class="pageNumber"></span> of <span class="totalPages"></span>12        </div>13        """)14    .margin(new PdfPageMargin("25mm", "20mm", null, null))15    .build());1617Files.write(Path.of("header-footer.pdf"),18    client.getFile(GetFileParams.builder().documentId(report.getId()).build()));19System.out.println("Header/footer -> " + report.getId());
  • Declare a font-size inline. Header templates default to a size that is barely legible.
  • The header is a separate document, so none of the page's CSS applies. Inline styles are the only option.
  • The two null arguments to PdfPageMargin keep the left and right margins at their defaults.

A logo in the header, a coloured band behind it or a custom typeface each need one more step, all of them described in headers and footers.

Templates with your own data

An invoice, a contract or a report starts as objects in your code. For a small document, a text block with formatted() and a stream that builds the rows is enough, and it needs no dependency.

1import java.util.List;2import java.util.stream.Collectors;34Order order = new Order("2026-0142", "Acme GmbH", List.of(5    new OrderLine("API plan, Business", 1, 79.00),6    new OrderLine("Extra documents", 2000, 0.01)));78String rows = order.lines().stream()9    .map(line -> "<tr><td>%s</td><td>%,d</td><td>EUR %,.2f</td></tr>"10        .formatted(line.description(), line.quantity(), line.quantity() * line.unitPrice()))11    .collect(Collectors.joining("\n"));1213String html = """14    <h1>Invoice #%s</h1>15    <p>Billed to %s</p>16    <table>17      <tr><th>Item</th><th>Qty</th><th>Amount</th></tr>18      %s19    </table>20    <p>Total EUR %,.2f</p>21    """.formatted(order.number(), order.customer(), rows,22        order.lines().stream().mapToDouble(l -> l.quantity() * l.unitPrice()).sum());2324PdfGateDocument templated = client.generatePdf(GeneratePdfParams.builder()25    .html(html)26    .pageSizeType(PageSizeType.A4)27    .build());2829Files.write(Path.of("template.pdf"),30    client.getFile(GetFileParams.builder().documentId(templated.getId()).build()));31System.out.println("Template -> " + templated.getId());323334record Order(String number, String customer, List<OrderLine> lines) {}35record OrderLine(String description, int quantity, double unitPrice) {}
  • Anything that came from a user must be HTML-escaped before it goes into the string. Spring's HtmlUtils.htmlEscape or the OWASP Java Encoder both do it. A customer name containing <script> is otherwise rendered as markup.
  • For anything larger than a page, use a real template engine. Thymeleaf renders to a string outside a web request with TemplateEngine.process, and FreeMarker and Mustache work the same way. Send the resulting string as html.
  • %,d and %,.2f use the default locale. Pass a Locale to String.format if the server and the reader are in different countries.

Once the document has more than a page of markup, a real template engine pays for itself, and in Java that usually means Thymeleaf. It renders to a string outside any web request, so the same template that serves a web page can produce the PDF. Add the dependency, then process the template with a StringTemplateResolver when the markup lives in code, or the default classpath resolver when it lives in src/main/resources/templates.

1implementation "org.thymeleaf:thymeleaf:3.1.3.RELEASE"
1import java.util.List;2import java.util.Map;3import org.thymeleaf.TemplateEngine;4import org.thymeleaf.context.Context;5import org.thymeleaf.templateresolver.StringTemplateResolver;67TemplateEngine engine = new TemplateEngine();8engine.setTemplateResolver(new StringTemplateResolver());910String template = """11    <h1>Invoice #[[${order.number}]]</h1>12    <p>Billed to [[${order.customer}]]</p>13    <table>14      <tr><th>Item</th><th>Qty</th><th>Amount</th></tr>15      <tr th:each="line : ${order.lines}">16        <td th:text="${line.description}"></td>17        <td th:text="${#numbers.formatInteger(line.quantity, 1, 'COMMA')}"></td>18        <td th:text="'EUR ' + ${#numbers.formatDecimal(line.quantity * line.unitPrice, 1, 'COMMA', 2, 'POINT')}"></td>19      </tr>20    </table>21    <p>Total EUR [[${#numbers.formatDecimal(total, 1, 'COMMA', 2, 'POINT')}]]</p>22    """;2324Map<String, Object> order = Map.of(25    "number", "2026-0142",26    "customer", "Acme GmbH",27    "lines", List.of(28        Map.of("description", "API plan, Business", "quantity", 1, "unitPrice", 79.00),29        Map.of("description", "Extra documents", "quantity", 2000, "unitPrice", 0.01)));3031Context context = new Context();32context.setVariable("order", order);33context.setVariable("total", 99.00);3435String html = engine.process(template, context);3637PdfGateDocument invoice = client.generatePdf(GeneratePdfParams.builder()38    .html(html)39    .pageSizeType(PageSizeType.A4)40    .build());4142Files.write(Path.of("thymeleaf.pdf"),43    client.getFile(GetFileParams.builder().documentId(invoice.getId()).build()));44System.out.println("Thymeleaf -> " + invoice.getId());

The [[...]] syntax inlines a value with HTML escaping, and th:text does the same for an attribute, so user data cannot inject markup. #numbers handles the thousands separators and decimals. The order is a Map here, which is what was run, and a bean with getters works the same way. This listing was executed against the API like every other one on the page, and the PDF it produced matches the formatted() version above.

Pages that render with JavaScript

Anything a page draws after its load event, charts included, is missing from the PDF unless the renderer waits for it. Have the page add a marker element once it has finished, then pass that selector to waitForSelector. The listing uses a timer to stand in for a slow API call.

1String chartHtml = """2    <h1>Sales report</h1>3    <div id="chart">Loading…</div>4    <script>5      // Stands in for a chart library or a fetch() that finishes later6      setTimeout(() => {7        document.getElementById("chart").textContent = "Q4 revenue: EUR 1.2M";8        document.body.insertAdjacentHTML("beforeend", "<div id=\\"chart-ready\\"></div>");9      }, 800);10    </script>11    """;1213PdfGateDocument chart = client.generatePdf(GeneratePdfParams.builder()14    .html(chartHtml)15    .waitForSelector("#chart-ready")16    .build());1718Files.write(Path.of("wait.pdf"),19    client.getFile(GetFileParams.builder().documentId(chart.getId()).build()));20System.out.println("Wait -> " + chart.getId());
  • A selector that never appears ends the request with a 422 after 30 seconds, rather than returning an incomplete document.
  • waitForNetworkIdle suits third-party pages where you cannot add a marker, and delay is a last-resort fixed wait.
  • The doubled backslash inside the text block is Java escaping. The page receives one backslash, as the inline script needs.

When to use which strategy is covered in waiting for dynamic content.

Error handling

All client methods declare IOException. When the API itself rejects a request, the exception is a PdfGateException, which adds getStatusCode() and getResponseBody() and puts the API's explanation into the message. The listing triggers one deliberately with a loopback URL.

1import com.pdfgate.PdfGateException;23try {4  client.generatePdf(GeneratePdfParams.builder().url("http://localhost:3000/report").build());5} catch (PdfGateException e) {6  System.out.println("API error " + e.getStatusCode() + ": " + e.getMessage());7}

The codes worth handling:

  • 400 is a rejected parameter. The message says which one, here the URL, elsewhere perhaps a malformed pageRanges.
  • 401 means the API key was absent or wrong.
  • 422 is a render failure on a page that did load, and the usual cause is a waitForSelector that reached its 30 second limit.
  • 429 means the plan's concurrent render limit was hit. Wait briefly and resend.

Connection failures come through as the base IOException from OkHttp. Catch PdfGateException first when the two need different handling.

In Spring Boot

Keep one PdfGate for the application and inject it into the controller. The first mapping returns the PDF bytes with the PDF media type and a download header. The second requests a temporary link through preSignedUrlExpiresIn and returns JSON, which keeps large files away from the web tier.

1import com.pdfgate.GeneratePdfParams;2import com.pdfgate.GeneratePdfParams.PageSizeType;3import com.pdfgate.GetFileParams;4import com.pdfgate.PdfGate;5import com.pdfgate.PdfGateDocument;6import java.io.IOException;7import java.time.LocalDate;8import java.util.Map;9import org.springframework.http.HttpHeaders;10import org.springframework.http.MediaType;11import org.springframework.http.ResponseEntity;12import org.springframework.web.bind.annotation.GetMapping;13import org.springframework.web.bind.annotation.PathVariable;14import org.springframework.web.bind.annotation.PostMapping;15import org.springframework.web.bind.annotation.RestController;1617@RestController18public class InvoiceController {19  private final PdfGate client = new PdfGate(System.getenv("PDFGATE_API_KEY"));2021  // Returns the PDF directly22  @GetMapping(value = "/invoices/{number}/pdf", produces = MediaType.APPLICATION_PDF_VALUE)23  public ResponseEntity<byte[]> pdf(@PathVariable String number) throws IOException {24    String html = "<h1>Invoice #" + number + "</h1><p>Generated on " + LocalDate.now() + "</p>";2526    PdfGateDocument document = client.generatePdf(GeneratePdfParams.builder()27        .html(html)28        .pageSizeType(PageSizeType.A4)29        .build());3031    byte[] pdf = client.getFile(GetFileParams.builder().documentId(document.getId()).build());32    return ResponseEntity.ok()33        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"invoice-" + number + ".pdf\"")34        .body(pdf);35  }3637  // Returns a temporary download link instead of the bytes38  @PostMapping("/invoices/{number}/pdf-link")39  public Map<String, Object> pdfLink(@PathVariable String number) throws IOException {40    PdfGateDocument document = client.generatePdf(GeneratePdfParams.builder()41        .html("<h1>Invoice #" + number + "</h1>")42        .preSignedUrlExpiresIn(3600L)43        .build());4445    return Map.of(46        "id", document.getId(),47        "fileUrl", document.getFileUrl().orElse(""),48        "expiresAt", document.getExpiresAt());49  }50}
  • A production service would declare the client as a @Bean with the key injected from configuration. The field initialiser keeps the listing short.
  • Letting IOException propagate hands API errors to Spring's exception handlers. Translate PdfGateException to a 502 or forward its message as the caller needs.
  • The link endpoint only works when the account has file storage turned on. Without it the API replies 422. A link lives between one minute and one day.
  • generatePdfAsync returns a CompletableFuture, which is the tool for batches, provided the number in flight stays within the plan's concurrency limit.

Without the SDK (java.net.http)

The SDK wraps one endpoint, POST /generate/pdf, which accepts JSON and responds with the PDF. The JDK's own HttpClient can call it directly if adding a dependency is not an option.

1import java.net.URI;2import java.net.http.HttpClient;3import java.net.http.HttpRequest;4import java.net.http.HttpResponse;5import java.nio.charset.StandardCharsets;6import java.nio.file.Files;7import java.nio.file.Path;89String body = """10    {11      "html": "<h1>Hello from java.net.http</h1>",12      "pageSizeType": "a4",13      "margin": { "top": "20mm", "bottom": "20mm" }14    }15    """;1617HttpRequest request = HttpRequest.newBuilder()18    .uri(URI.create("https://api.pdfgate.com/generate/pdf"))19    .header("Authorization", "Bearer " + System.getenv("PDFGATE_API_KEY"))20    .header("Content-Type", "application/json")21    .POST(HttpRequest.BodyPublishers.ofString(body))22    .build();2324HttpResponse<byte[]> response = HttpClient.newHttpClient()25    .send(request, HttpResponse.BodyHandlers.ofByteArray());2627if (response.statusCode() != 201) {28  System.out.println(new String(response.body(), StandardCharsets.UTF_8));29  return;30}31Files.write(Path.of("http.pdf"), response.body());32System.out.println("HttpClient -> http.pdf");
  • Check for 201, which is the success code. With jsonResponse set to true the body becomes the document record instead.
  • Point test_ keys at api-sandbox.pdfgate.com yourself. Host selection by key prefix is something the SDK does and plain HTTP does not.
  • The request body here is a literal string. Real code should serialise a map with Jackson or Gson so that quotes inside the HTML are escaped.

Where to go next

  • Forms. enableFormFields turns HTML inputs into real PDF fields. The fillable fields guide lists the supported inputs, and flattening a PDF in Java shows how to lock them afterwards.
  • Pages that need a session. Send the cookie or token in httpHeaders, or basic auth in authentication, see authenticated pages.
  • Overlays and banners. Describe the clicks that close them in clickSelectorChainSetup and the renderer performs them first, as in removing cookie banners.
  • Fonts. Liberation and a colour emoji face are the only system fonts on the renderer, so corporate typefaces are loaded as web fonts, see custom fonts and images.
  • The Java edition has siblings. C#, Python, Node.js and PHP cover the same ground in their own idioms.
  • When you need the full list. The HTML to PDF API reference has every request field, and the Java SDK documentation the methods this tutorial did not call.

Frequently asked questions

Which Java versions does the SDK support?

Every LTS JDK from 8 to 25. The samples use text blocks and records, which need Java 16 or later; on Java 8 or 11 replace them with concatenated strings and small classes.

How do I add page numbers to a PDF generated from Java?

Pass header and footer HTML with the pageNumber and totalPages CSS classes, and set the top and bottom margins so there is room to draw them. The renderer fills the classes in on every page.

Can I use Thymeleaf or FreeMarker as the template?

Yes. Render the template to a string, with TemplateEngine.process in Thymeleaf, and send the string as html. Escape any user input with HtmlUtils.htmlEscape or the OWASP Java Encoder first.

What happens when a request fails?

Every method declares IOException. An API error arrives as PdfGateException, a subclass with getStatusCode and getResponseBody. A 400 means an invalid parameter, 401 a bad key, 422 a page that could not be rendered, and 429 more concurrent conversions than the plan allows.

For the options this tutorial left out, start from the HTML to PDF API page.

This website uses cookies to offer a better browsing experience. By clicking on the "Accept" button you accept our Cookie policy.