HTML to PDF in Java

Three ways to turn HTML into a real, text-selectable PDF from Java, all run on the same invoice on JDK 25, plus the libraries you should skip. Tested with OpenHTMLtoPDF 1.1.97, Playwright for Java 1.63 and plain java.net.http.

Quick answer

OptionPick it when
OpenHTMLtoPDF
Pure Java, no browser, LGPL
Your templates are simple (CSS 2.1 layout: blocks, tables, floats) and you want everything in the JVM.
Playwright for Java
By Microsoft, drives Chromium
Your HTML uses flexbox, grid, JavaScript or web fonts and you can run a browser next to your app.
An API
Chromium on someone else's server
Lambda, slim containers, or apps where a ~150 MB browser per instance isn't worth running. No dependencies at all.

Skip these: the original com.openhtmltopdf artifacts stopped at 1.0.10; the maintained fork is io.github.openhtmltopdf. Flying Saucer is the older engine OpenHTMLtoPDF grew out of. wkhtmltopdf wrappers ship a 2012-era WebKit whose project is archived (wkhtmltopdf alternative). iText's pdfHTML is good but AGPL or a commercial license, so read the terms before you ship it in a closed product.

The test template

Save this as invoice.html in the project root. The @page rule sets the paper size, margins and page numbers, and all three options below honor it:

<!doctype html>
<html><head><meta charset="utf-8"><style>
  @page { size: A4; margin: 20mm 15mm;
    @bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 9px; } }
  body { font-family: sans-serif; }
  .top { background: #eef2ff; padding: 12px; }
  table { width: 100%; border-collapse: collapse; }
  td, th { border-bottom: 1px solid #ddd; padding: 6px; text-align: left; }
  tr { page-break-inside: avoid; }
</style></head><body>
  <div class="top"><h1>Invoice #1042</h1><p>Due: 2026-11-01</p></div>
  <table><tr><th>Item</th><th>Price</th></tr>
    <tr><td>Consulting (3h)</td><td>€450.00</td></tr>
    <tr><td>Hosting, Oct.</td><td>€19.00</td></tr></table>
</body></html>

The Maven setup

One pom.xml for the two library options (the API option needs no dependencies):

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>invoice-pdf</artifactId>
  <version>1.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>io.github.openhtmltopdf</groupId>
      <artifactId>openhtmltopdf-pdfbox</artifactId>
      <version>1.1.97</version>
    </dependency>
    <dependency>
      <groupId>org.jsoup</groupId>
      <artifactId>jsoup</artifactId>
      <version>1.23.2</version>
    </dependency>
    <dependency>
      <groupId>com.microsoft.playwright</groupId>
      <artifactId>playwright</artifactId>
      <version>1.63.0</version>
    </dependency>
  </dependencies>
</project>

Run each class with mvn -q compile exec:java -Dexec.mainClass=OpenHtmlPdf (the exec plugin resolves by its prefix, no extra config needed). Gradle: the same three coordinates as implementation lines.

1. OpenHTMLtoPDF (pure Java)

src/main/java/OpenHtmlPdf.java:

import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import org.jsoup.Jsoup;
import org.jsoup.helper.W3CDom;

import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStream;

public class OpenHtmlPdf {
    public static void main(String[] args) throws Exception {
        File html = new File("invoice.html");
        // jsoup turns loose HTML5 into the well-formed DOM OpenHTMLtoPDF needs
        org.jsoup.nodes.Document doc = Jsoup.parse(html, "UTF-8");
        try (OutputStream out = new FileOutputStream("invoice.pdf")) {
            PdfRendererBuilder builder = new PdfRendererBuilder();
            builder.useFastMode();
            builder.withW3cDocument(new W3CDom().fromJsoup(doc), html.toURI().toString());
            builder.toStream(out);
            builder.run();
        }
    }
}

Result: an A4 PDF with the blue header bar, selectable text, the euro amounts and "Page 1 of 1" at the bottom right. Things that trip people up:

2. Playwright for Java (Chromium)

src/main/java/PlaywrightPdf.java:

import com.microsoft.playwright.*;

import java.nio.file.Files;
import java.nio.file.Path;

public class PlaywrightPdf {
    public static void main(String[] args) throws Exception {
        String html = Files.readString(Path.of("invoice.html"));
        try (Playwright playwright = Playwright.create();
             Browser browser = playwright.chromium().launch()) {
            Page page = browser.newPage();
            page.setContent(html);
            page.pdf(new Page.PdfOptions()
                .setPath(Path.of("invoice-pw.pdf"))
                .setPreferCSSPageSize(true) // use the @page size and margins from the template
                .setPrintBackground(true));
        }
    }
}

The first Playwright.create() downloads its Chromium build (about 150 MB) to ~/.cache/ms-playwright. On a fresh Linux server, install the system libraries once with mvn exec:java -e -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install-deps chromium" (needs root); without them it prints a "missing dependencies" warning. The output matches what Chrome prints, so flexbox, grid, web fonts and scripts all work. Notes:

3. An API (no browser, no dependencies)

Shotmint renders in the same Chromium, but your app only makes an HTTP call with the JDK's built-in client: no browser download, no fonts, nothing to keep alive. Run it as a single file with SHOTMINT_KEY=... java ApiPdf.java:

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;

public class ApiPdf {
    public static void main(String[] args) throws Exception {
        String html = Files.readString(Path.of("invoice.html"));
        String body = "{\"html\":" + json(html) + ",\"format\":\"A4\",\"margin\":\"20mm\"}";

        HttpRequest req = HttpRequest.newBuilder(URI.create("https://shotmint.creasework.com/v1/pdf"))
            .timeout(Duration.ofSeconds(90))
            .header("X-API-Key", System.getenv("SHOTMINT_KEY"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();
        HttpResponse<byte[]> res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofByteArray());
        if (res.statusCode() != 200)
            throw new RuntimeException("Render failed (" + res.statusCode() + "): " + new String(res.body()));
        Files.write(Path.of("invoice-api.pdf"), res.body());
    }

    // Minimal JSON string escaping, so there's no dependency. With Jackson: new ObjectMapper().writeValueAsString(html)
    static String json(String s) {
        StringBuilder b = new StringBuilder("\"");
        for (char c : s.toCharArray()) {
            switch (c) {
                case '"' -> b.append("\\\"");
                case '\\' -> b.append("\\\\");
                case '\n' -> b.append("\\n");
                case '\r' -> b.append("\\r");
                case '\t' -> b.append("\\t");
                default -> {
                    if (c < 0x20) b.append(String.format("\\u%04x", (int) c));
                    else b.append(c);
                }
            }
        }
        return b.append('"').toString();
    }
}

Result: the same 1-page A4 PDF as Playwright, page number included. A bad key throws Render failed (401): {"error":"Missing or invalid API key..."}; errors are always JSON with a 4xx status. For a live page, send "url" instead of "html". Other options: landscape, format (A3, A4, A5, Letter, Legal, Tabloid), scale, print_background (on by default) and screen_media. Already using Jackson or Gson? Build the body with them instead of the json() helper. Images and fonts must be reachable from the internet (or inlined as data URIs). In Spring Boot, see HTML to PDF in Spring Boot for a complete Thymeleaf endpoint. Screenshots from Java: website screenshots in Java.

Side by side

FeatureSupport
CSSPlaywright and API: everything Chrome supports
OpenHTMLtoPDF: CSS 2.1 + paged media, no flexbox/grid/JS
Page numbersAll three: CSS @page margin boxes
Playwright also: footer templates
InstallAPI: nothing (JDK 11+ HttpClient)
OpenHTMLtoPDF: ~6 MB of jars; Playwright: a ~200 MB driver-bundle jar (a Node driver for every OS) plus a ~150 MB Chromium and system libraries
Private pagesOpenHTMLtoPDF and Playwright: can reach localhost and your VPN
API: public URLs or HTML you send

Which one?

If your templates are simple and you want zero moving parts outside the JVM, OpenHTMLtoPDF is free and fast. If they use modern CSS and your app can run a browser, use Playwright. If it can't (Lambda, a small container) or you'd rather not keep Chromium patched and fed with fonts, use the API. Want to see the output first? Paste your HTML into the free HTML to PDF converter; no key needed. Other languages: C# / .NET, Node.js, Python.

Try it with 25 free renders a month

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons