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.
| Option | Pick 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.
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>
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.
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:
withHtmlContent() parses the input as XML, so an unclosed <meta> or <br> is a parse error. Running it through jsoup and W3CDom first, as above, accepts normal HTML5.display: flex header renders as stacked blocks, so use tables or floats for side-by-side parts.builder.useFont(new File("NotoSans-Regular.ttf"), "Noto Sans") and use that family in your CSS; it is embedded in the PDF.withW3cDocument), here the folder of invoice.html.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:
setPrintBackground(true): off by default, so background colors and images vanish without it.setPreferCSSPageSize(true) uses the template's @page size and margins. Without it, Chromium uses Letter with default margins unless you pass setFormat("A4") and setMargin(...).setDisplayHeaderFooter(true) plus setFooterTemplate("...<span class='pageNumber'></span>...") works, but drop the @bottom-right rule or you get the page number twice.Browser at startup and open a new Page (or BrowserContext) per request; a Playwright instance is not thread-safe, so use one per thread.page.navigate("https://example.com") instead of setContent. PDF export only works in Chromium, not Firefox or WebKit.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.
| Feature | Support |
|---|---|
| CSS | Playwright and API: everything Chrome supports OpenHTMLtoPDF: CSS 2.1 + paged media, no flexbox/grid/JS |
| Page numbers | All three: CSS @page margin boxesPlaywright also: footer templates |
| Install | API: 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 pages | OpenHTMLtoPDF and Playwright: can reach localhost and your VPN API: public URLs or HTML you send |
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.
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).
All guides: guides · integrations · comparisons