HTML-to-PDF in Java has a quality spectrum: Flying Saucer handles clean XHTML well; OpenHTMLtoPDF adds CSS3 support and a PDFBox backend. Both struggle with JavaScript-rendered content and modern CSS (flexbox, grid). For the full browser rendering pipeline — the same output Chrome's print-to-PDF gives — the ChangeThisFile API runs a headless browser server-side. No local browser install, no ChromeDriver, just a HttpClient POST.
Method 1: Flying Saucer (xhtmlrenderer + iText)
Flying Saucer is the original Java HTML-to-PDF library. It requires well-formed XHTML and supports CSS 2.1. Use it for internal report generation where you control the HTML template.
<dependency>
<groupId>org.xhtmlrenderer</groupId>
<artifactId>flying-saucer-pdf-itext5</artifactId>
<version>9.8.1</version>
</dependency>
import org.xhtmlrenderer.pdf.ITextRenderer;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.nio.file.Path;
public class HtmlToPdfFlyingSaucer {
public static void convert(String htmlContent, Path pdfPath) throws Exception {
// Flying Saucer requires well-formed XHTML
ITextRenderer renderer = new ITextRenderer();
renderer.setDocumentFromString(htmlContent);
renderer.layout();
try (OutputStream out = new FileOutputStream(pdfPath.toFile())) {
renderer.createPDF(out);
}
}
public static void main(String[] args) throws Exception {
String html = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Report</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
h1 { color: #333; }
table { border-collapse: collapse; width: 100%; }
td, th { border: 1px solid #ccc; padding: 8px; }
</style>
</head>
<body>
<h1>Monthly Report</h1>
<p>Generated on 2026-04-25.</p>
</body>
</html>
""";
convert(html, Path.of("report.pdf"));
System.out.println("Saved report.pdf");
}
}
Flying Saucer requires XHTML — HTML5 documents without proper XML structure will throw a parse error. If you're converting arbitrary HTML from the web, use Method 2 or 3.
Method 2: OpenHTMLtoPDF (modern fork, CSS3 + PDFBox backend)
OpenHTMLtoPDF is the actively maintained fork of Flying Saucer. It supports HTML5 parsing via jsoup, CSS3 features including flexbox basics, and uses PDFBox as its default backend (Apache 2.0).
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>1.0.10</version>
</dependency>
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-slf4j</artifactId>
<version>1.0.10</version>
</dependency>
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
public class HtmlToPdfOpenHtml {
public static void convert(String htmlContent, Path pdfPath) throws Exception {
try (OutputStream out = new FileOutputStream(pdfPath.toFile())) {
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.useFastMode();
builder.withHtmlContent(htmlContent, "/"); // Base URI for relative paths
builder.toStream(out);
builder.run();
}
}
public static void convertUrl(String url, Path pdfPath) throws Exception {
String html = new String(new java.net.URL(url).openStream().readAllBytes());
convert(html, pdfPath);
}
public static void main(String[] args) throws Exception {
String html = Files.readString(Path.of("report.html"));
convert(html, Path.of("report.pdf"));
System.out.println("Saved report.pdf");
}
}
OpenHTMLtoPDF is significantly better than Flying Saucer for modern HTML but still has limits: no JavaScript execution, partial flexbox support, and no CSS grid. For static HTML templates you control, it's excellent.
Method 3: ChangeThisFile API (Java 11 HttpClient, full browser rendering)
The API runs a headless browser server-side — full CSS3, JavaScript-rendered content, print media queries. Send HTML as a multipart POST with Java 11's built-in HttpClient. Free tier covers 1,000 conversions/month.
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class HtmlToPdfApi {
private static final String API_KEY = "ctf_sk_your_key_here";
private static final String API_URL = "https://changethisfile.com/v1/convert";
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
public static byte[] convertFile(Path htmlPath) throws IOException, InterruptedException {
String boundary = "----CTFBoundary" + UUID.randomUUID().toString().replace("-", "");
byte[] fileBytes = Files.readAllBytes(htmlPath);
List<byte[]> parts = new ArrayList<>();
parts.add(("--" + boundary + "\r\n" +
"Content-Disposition: form-data; name=\"target\"\r\n\r\npdf\r\n").getBytes(StandardCharsets.UTF_8));
parts.add(("--" + boundary + "\r\n" +
"Content-Disposition: form-data; name=\"file\"; filename=\"page.html\"\r\n" +
"Content-Type: text/html\r\n\r\n").getBytes(StandardCharsets.UTF_8));
parts.add(fileBytes);
parts.add(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
int totalLen = parts.stream().mapToInt(b -> b.length).sum();
byte[] body = new byte[totalLen];
int offset = 0;
for (byte[] part : parts) {
System.arraycopy(part, 0, body, offset, part.length);
offset += part.length;
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.header("Authorization", "Bearer " + API_KEY)
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.timeout(Duration.ofSeconds(60))
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
HttpResponse<byte[]> response = HTTP.send(request,
HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
throw new IOException("API error " + response.statusCode() +
": " + new String(response.body()));
}
return response.body();
}
public static void main(String[] args) throws Exception {
byte[] pdf = convertFile(Path.of("report.html"));
Files.write(Path.of("report.pdf"), pdf);
System.out.println("Saved report.pdf (" + pdf.length + " bytes)");
}
}
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| Flying Saucer | Clean XHTML templates, CSS 2.1, report generation | Requires valid XHTML; no HTML5 or CSS3 |
| OpenHTMLtoPDF | HTML5 templates, better CSS3 support, Apache 2.0 | No JavaScript execution; partial flexbox/grid |
| ChangeThisFile API | Arbitrary HTML, JS-rendered content, modern CSS | Network round-trip; 25MB limit on free tier |
Production tips
- Use CompletableFuture for concurrent PDF generation. Flying Saucer and OpenHTMLtoPDF are thread-safe for rendering independent documents — wrap each job in
CompletableFuture.supplyAsync()with a bounded executor to process report queues in parallel. - For paginated reports, use CSS @page rules. Flying Saucer and OpenHTMLtoPDF both support
@page { size: A4; margin: 20mm; }. Add running headers/footers using the-fs-table-paginate: paginateCSS extension in Flying Saucer. - Embed fonts as base64 data URIs. When generating PDFs in environments without a predictable system font path, embed fonts inline:
@font-face { src: url('data:font/woff2;base64,...'); }. This avoids font-not-found errors in containerized deployments. - For the API, reuse HttpClient. Create one
HttpClientat class level — it maintains connection pooling and amortizes TLS handshake overhead across multiple PDF generation requests. - Validate HTML before sending to Flying Saucer. Use jsoup's
Jsoup.parse(html).outputSettings().syntax(Document.OutputSettings.Syntax.xml).html()to convert HTML5 to clean XHTML before passing to Flying Saucer.
For internal reports with templated XHTML, OpenHTMLtoPDF gives the best balance of quality and simplicity (Apache 2.0, HTML5 input). For arbitrary HTML from the web or JS-rendered pages, the ChangeThisFile API is the only Java option that reliably handles modern CSS. Free tier covers 1,000 conversions/month.