- HTML to PDF APIConvert HTML and URLs into PDFsExtract PDF Form Data APIEasily extract data from PDFsWatermark PDF APIAdd custom watermarks to PDFsProtect PDF APISecure your PDFs with passwordCompress PDF APIReduce file size without losing qualityFlatten PDF APIFlatten PDFs to make form permanentDigital Signature APISend documents for signing
- API DocumentationAPI DocumentationFull REST API referenceNodeJS SDKClient library for Node.jsJava SDKClient library for JavaC# SDKClient library for C#PHP SDKClient library for PHPPython SDKClient library for Python
- Pricing
- Contact Us

HTML to PDF in Java: Thymeleaf Templates to Finished PDFs
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:
generatePdfrenders,getFilereturns the bytes. No Flying Saucer limits, no iText licence, no browser on the server. - Every request is a builder. Set
pageSizeTypeexplicitly, the default is Ledger, and pass margins as aPdfPageMargin. - 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
PdfGateclient 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
localhostaddress fails fast with a400. - Unless you choose a size, the output is Ledger.
GeneratePdfParams.PageSizeTypeis a nested enum with A0 to A6, Letter, Legal, Ledger and Tabloid. Pixel dimensions go inwidthandheight. getFilereturnsbyte[], 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
PdfGateand 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)");PdfPageMarginhas a four-argument constructor in the order top, bottom, left, right. Values are CSS lengths, andnullkeeps 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, andemulateMediaType(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-sizeinline. 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
nullarguments toPdfPageMarginkeep 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.htmlEscapeor 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 ashtml. %,dand%,.2fuse the default locale. Pass aLocaletoString.formatif 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
422after 30 seconds, rather than returning an incomplete document. waitForNetworkIdlesuits third-party pages where you cannot add a marker, anddelayis 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:
400is a rejected parameter. The message says which one, here the URL, elsewhere perhaps a malformedpageRanges.401means the API key was absent or wrong.422is a render failure on a page that did load, and the usual cause is awaitForSelectorthat reached its 30 second limit.429means 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
@Beanwith the key injected from configuration. The field initialiser keeps the listing short. - Letting
IOExceptionpropagate hands API errors to Spring's exception handlers. TranslatePdfGateExceptionto a502or 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. generatePdfAsyncreturns aCompletableFuture, 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. WithjsonResponseset totruethe body becomes the document record instead. - Point
test_keys atapi-sandbox.pdfgate.comyourself. 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.
enableFormFieldsturns 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 inauthentication, see authenticated pages. - Overlays and banners. Describe the clicks that close them in
clickSelectorChainSetupand 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.