Testing & Performance with iText
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
A PDF generator is easy to break without noticing. A change in a margin, a font fallback that silently swaps a glyph or a
table that loses its last row does not throw an exception, and the file still opens. It is also easy to make slow: a
report that renders in a second with 100 rows can exhaust the heap with 100,000. This page covers both halves for the
Bookshelf Billing invoice service: how to test generated PDFs with CompareTool, text-extraction assertions and golden
files under JUnit 5, and how to keep memory and time under control with immediate flush, large tables, shared fonts and,
when start-up time matters, GraalVM native image. Versions are stated as of 2026-10-09: iText Core 9.8.0 on Java 21 with
JUnit 5.14.4, licensed under the AGPLv3 or under a commercial licence (see the Introduction & Approaches page of this
section for the consequences). All the code on this page was compiled and run against these versions, except the two
pieces that need tools that were not installed on the test machine: the visual comparison (Ghostscript and ImageMagick)
and the native image build (GraalVM). Both are marked where they appear.
What to test, and with which tool
A generated PDF can be checked at four levels. The cheaper levels run first and catch most regressions; the expensive ones catch what the cheaper ones cannot see.
| Level | What it proves | Tool |
|---|---|---|
Values |
The numbers, names and dates that went into the document came out, in the expected order. |
|
Structure |
Page count, metadata, fonts, form fields, outlines, attachments and tags are what the code intended. |
The |
Content streams |
The drawing operators, resources and objects equal those of a reviewed reference file. |
|
Pixels |
The rendered pages look the same, even if the internal objects differ. |
|
Text assertions do not replace a golden file, and a golden file does not replace a text assertion. The text assertion tells you which value is wrong in plain words; the golden file tells you that something, anywhere, changed. The sections below follow the table from top to bottom.
The code under test
The examples use one small generator: a record-based invoice written with one iText table. It is deliberately plain,
because the interesting part is the tests. Maven coordinates (first use on this page): itext-core is a POM aggregator,
so it is declared with <type>pom</type>, and JUnit Jupiter is a test dependency. The Bouncy Castle adapter is not
needed, because nothing is signed or encrypted here.
<dependencies>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itext-core</artifactId>
<version>9.8.0</version>
<type>pom</type>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.14.4</version>
<scope>test</scope>
</dependency>
</dependencies>
The generator takes an OutputStream instead of a file name. That one decision makes the code testable: a test passes a
ByteArrayOutputStream and gets the bytes back with no temporary file, and a controller passes the servlet response.
The record types are the Java 21 idiom for the input: immutable, with equals and toString for free.
package com.example.billing;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Cell;
import com.itextpdf.layout.element.Paragraph;
import com.itextpdf.layout.element.Table;
import com.itextpdf.layout.properties.UnitValue;
import java.io.IOException;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.util.List;
public final class InvoicePdf {
public record Line(String title, int quantity, BigDecimal unitPrice) {
BigDecimal total() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
}
public record Invoice(String number, String customer, List<Line> lines) {
BigDecimal total() {
return lines.stream().map(Line::total).reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
private InvoicePdf() {
}
public static void write(Invoice invoice, OutputStream out) throws IOException {
try (var pdf = new PdfDocument(new PdfWriter(out));
var document = new Document(pdf)) {
pdf.getDocumentInfo().setTitle("Invoice " + invoice.number());
document.add(new Paragraph("Bookshelf Billing").setFontSize(20));
document.add(new Paragraph("Invoice " + invoice.number()));
document.add(new Paragraph("Customer: " + invoice.customer()));
var table = new Table(UnitValue.createPercentArray(new float[] {5, 1, 2, 2}))
.useAllAvailableWidth();
for (String header : List.of("Title", "Qty", "Unit price", "Total")) {
table.addHeaderCell(new Cell().add(new Paragraph(header)));
}
for (Line line : invoice.lines()) {
table.addCell(line.title());
table.addCell(String.valueOf(line.quantity()));
table.addCell(line.unitPrice().toPlainString());
table.addCell(line.total().toPlainString());
}
document.add(table);
document.add(new Paragraph("Total: " + invoice.total().toPlainString() + " EUR"));
}
}
}
Reference: Document and
the jump-start tutorial, chapter 1, from which the
PdfWriter, PdfDocument, Document construction order derives. The lifecycle is explained in
Document Lifecycle, and the table API in
Tables.
Asserting on extracted text
Text extraction is the most useful assertion for generated documents, because it survives every change that does not
alter the words: a different margin, a new font, a reordered resource dictionary. The helper below opens the produced
bytes, extracts the text of every page with PdfTextExtractor and collapses runs of whitespace, so that a line break
introduced by a layout change does not break a test about values. A second helper asserts that fragments appear in a given
order, which is the natural way to say "the customer comes before the lines and the total comes last".
package com.example.billing;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.pdf.PdfDictionary;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfName;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.LinkedHashSet;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.fail;
/** Small helpers that turn PDF bytes into values a unit test can assert on. */
final class PdfAssert {
private PdfAssert() {
}
static PdfDocument open(byte[] pdf) throws IOException {
return new PdfDocument(new PdfReader(new ByteArrayInputStream(pdf)));
}
/** Text of every page, with runs of whitespace collapsed so that layout changes do not break the test. */
static String text(byte[] pdf) throws IOException {
try (PdfDocument document = open(pdf)) {
var all = new StringBuilder();
for (int page = 1; page <= document.getNumberOfPages(); page++) {
all.append(PdfTextExtractor.getTextFromPage(document.getPage(page))).append('\n');
}
return all.toString().replaceAll("\\s+", " ").trim();
}
}
/** Base font names used by the fonts of one page, for example "Helvetica". */
static Set<String> fontNames(PdfDocument document, int pageNumber) {
var names = new LinkedHashSet<String>();
PdfDictionary fonts = document.getPage(pageNumber).getResources().getResource(PdfName.Font);
if (fonts == null) {
return names;
}
for (PdfName key : fonts.keySet()) {
PdfFont font = PdfFontFactory.createFont(fonts.getAsDictionary(key));
names.add(font.getFontProgram().getFontNames().getFontName());
}
return names;
}
static void assertContainsInOrder(String text, String... fragments) {
int from = 0;
for (String fragment : fragments) {
int at = text.indexOf(fragment, from);
if (at < 0) {
fail("Expected '" + fragment + "' after position " + from + " in: " + text);
}
from = at + fragment.length();
}
}
}
Reference: Parsing PDFs in the iText knowledge base. The extraction
strategies and their limits are described in
Text Extraction & Parsing; the default strategy used
by getTextFromPage is location-based: it sorts the text chunks by position, which gives reading order for simple
layouts, but a multi-column page can interleave columns. Assert on fragments, in order, and not on the exact line a word
falls on; for position-sensitive checks use the strategies and regions of that page.
The tests then read as a specification. The last one uses a Java text block for the expected header, which keeps a
multi-line expectation readable and free of \n escapes.
@Test
void textContainsTheExpectedValuesInOrder() throws IOException {
String text = PdfAssert.text(render(INVOICE));
PdfAssert.assertContainsInOrder(text,
"Invoice 2026-0042", "Customer: Ada Example", "Dune", "25.00", "Neuromancer", "Total: 34.90 EUR");
}
@Test
void headerBlockMatchesTheTextBlock() throws IOException {
String expected = """
Bookshelf Billing
Invoice 2026-0042
Customer: Ada Example
""".replaceAll("\\s+", " ").trim();
assertTrue(PdfAssert.text(render(INVOICE)).startsWith(expected));
}
Reference: JUnit User Guide. The tests use JUnit 5 (Jupiter) features that are unchanged in the JUnit 6 line, so they run on both.
Asserting on structure
Values can be right while the document is wrong: the title missing from the metadata, a second page that should not be
there, a font that is not the corporate one. These properties are read from the PdfDocument. The PdfAssert.fontNames
helper above walks the font resources of a page and builds each PdfFont from its dictionary with
PdfFontFactory.createFont(PdfDictionary); the test checks that the invoice uses only Helvetica, which is one of the
standard 14 fonts and therefore not embedded. If you embed fonts, assert the embedded name instead, which also catches a
fallback font that quietly replaced the intended one.
@Test
void structureIsAsExpected() throws IOException {
try (PdfDocument pdf = PdfAssert.open(render(INVOICE))) {
assertEquals(1, pdf.getNumberOfPages());
assertEquals("Invoice 2026-0042", pdf.getDocumentInfo().getTitle());
assertEquals(Set.of("Helvetica"), PdfAssert.fontNames(pdf, 1));
}
}
Reference: PdfDocument. Embedding
and subsetting are explained in Fonts & Unicode. The same
approach applies to any other property: PdfDocument.getCatalog() for outlines and page labels, the PdfAcroForm for form
fields, or the tag structure for accessibility. A checker such as veraPDF (see
PDF Standards & Conformance) is the right tool for
whole-standard validation; a unit test should assert only the few properties that matter to the business.
CompareTool
com.itextpdf.kernel.utils.CompareTool is iText’s own regression tool. It ships in the kernel module, so it is already
on the classpath with itext-core, and it is what the iText project uses to test iText. It compares a freshly generated
file (the out file) with a reviewed reference (the cmp file, the name coming from the iText sources, which store such
files as cmp_*.pdf). Its report is a string: null means the documents are equal, anything else describes the first
differences.
Structural comparison
compareByContent(outPdf, cmpPdf, outDir, diffPrefix) starts at the page dictionaries and compares the objects they
refer to recursively: content streams (operators and operands), resources, fonts, annotations. It ignores what is
legitimately different between two runs: the object numbering, the trailer ID, the creation and modification dates and
the order of dictionary entries. The test below renders the invoice, writes it to a temporary directory managed by
JUnit’s @TempDir, and compares it with src/test/resources/cmp_invoice.pdf.
@Test
void matchesTheGoldenFile() throws Exception {
Path actual = tmp.resolve("invoice.pdf");
Files.write(actual, render(INVOICE));
if (Boolean.getBoolean("update.golden")) {
Files.copy(actual, GOLDEN, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
var compare = new CompareTool();
assertNull(compare.compareByContent(actual.toString(), GOLDEN.toString(), tmp.toString(), "diff_"));
assertNull(compare.compareDocumentInfo(actual.toString(), GOLDEN.toString()));
}
Reference: CompareTool. The API
page names the class, and the signatures used here (compareByContent, compareDocumentInfo, compareVisually) were
compiled against 9.8.0.
When the contents differ, the report names the object and the first differing byte, for example that an operand of a
Tj operator was 2 in the reference and 3 in the output, with the object number in both files. That is usually
enough to find the change. Three details from running it against 9.8.0:
-
A failing
compareByContentalso tries to render both files to produce difference images, so it calls Ghostscript. When Ghostscript is not configured the call throws aRuntimeExceptionthat names theITEXT_GS_EXECenvironment variable instead of returning a report. A passing comparison does not need any external tool. The practical consequence is that the CI image must have the tools installed even if you only use structural comparison, otherwise a regression surfaces as a confusing configuration error. -
compareDocumentInfo(outPdf, cmpPdf)compares the information dictionary (title, author, subject, keywords) and is a separate call, as in the test above. It does not compare the dates. -
The XMP packet, the link annotation destinations and the tag structure have dedicated methods,
compareXmp,compareLinkAnnotationsandcompareTagStructures, which you add to the test when the document is supposed to carry them (for example a PDF/UA invoice, covered on the PDF/A and PDF/UA page of this section).
Other knobs of the same class, all fluent setters returning the CompareTool:
-
setCompareByContentErrorsLimit(int)bounds how many differences are collected, which keeps the report short for a file that differs everywhere. -
setContentStreamFloatTolerance(float)accepts coordinates that differ by less than a tolerance. Use it when the same code runs on JVMs whose floating-point formatting differs in the last digit; the default is exact. -
setGenerateCompareByContentXmlReport(true)writes an XML report of the differences next to the images, which a CI job can attach to a failed build. -
enableEncryptionCompare()adds the encryption properties of the two documents to the comparison. -
CompareTool.createTestPdfWriter(path)returns a writer that keeps the output in memory, andcompareByContentthen reads it from there. In 9.8.0 no file is left on disk after the document is closed, so use a plainPdfWriterwhen you want to open the output in a viewer.
Visual comparison
compareVisually(outPdf, cmpPdf, outDir, diffPrefix) renders every page of both files to an image with Ghostscript and
compares the images with ImageMagick’s compare. It is the only check that proves two files look the same when their
internal structure differs, for example after upgrading iText or after replacing a font subset, and it writes
diff_<name>_<page>.png images that highlight the differing pixels in red. The other overloads add two options: a
Map<Integer, List<Rectangle>> from page number to areas to ignore, which is how a footer with the current date is
excluded, and a double fuzz, the ImageMagick tolerance for pixels that are "almost" the same colour. In 9.8.0 the fuzz
is accepted either as compareVisually(outPdf, cmpPdf, outDir, fuzz), without a diff prefix or ignored areas, or as the
full compareVisually(outPdf, cmpPdf, outDir, diffPrefix, ignoredAreas, fuzz), where ignoredAreas may be null; there
is no overload that takes the diff prefix and the fuzz alone.
The tools are located through two environment variables, which are the same ones the iText build uses (BUILDING.md in the iText repository):
export ITEXT_GS_EXEC=gs
export ITEXT_MAGICK_COMPARE_EXEC="magick compare"
mvn test
The syntax of the two commands depends on the installed versions (gs or gswin64c for Ghostscript, magick compare or
compare for ImageMagick 6). This repository’s test machine had neither tool, so the visual test below was run only as
far as its assumption: it is skipped when the variables are not defined, which is also the right behaviour for developers
who have not installed the tools, while CI sets them.
@Test
void looksTheSameWhenRendered() throws Exception {
assumeTrue(System.getenv("ITEXT_GS_EXEC") != null && System.getenv("ITEXT_MAGICK_COMPARE_EXEC") != null,
"Ghostscript and ImageMagick are not configured");
Path actual = tmp.resolve("invoice.pdf");
Files.write(actual, render(INVOICE));
assertNull(new CompareTool().compareVisually(actual.toString(), GOLDEN.toString(), tmp.toString(), "diff_"));
}
Reference: Ghostscript and ImageMagick compare. The visual check is slow (an image per page) and sensitive to the Ghostscript version, because font rasterisation changes between releases. Pin the version in the CI image, and keep the visual golden files few: one per template, not one per test.
The complete test class
The test methods above are fragments of one JUnit 5 class. Put together with the fields they share (the INVOICE input,
the GOLDEN path, the @TempDir directory and the render helper that calls the generator), it is the listing below.
It was compiled and run on Java 21 against iText 9.8.0 and JUnit Jupiter 5.14.4, first with -Dupdate.golden=true to
create src/test/resources/cmp_invoice.pdf and then without it: four tests pass, and looksTheSameWhenRendered is
reported as aborted, the JUnit term for a failed assumption, because Ghostscript and ImageMagick were not configured.
package com.example.billing;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.utils.CompareTool;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
class InvoicePdfTest {
private static final InvoicePdf.Invoice INVOICE = new InvoicePdf.Invoice("2026-0042", "Ada Example", List.of(
new InvoicePdf.Line("Dune", 1, new BigDecimal("25.00")),
new InvoicePdf.Line("Neuromancer", 1, new BigDecimal("9.90"))));
private static final Path GOLDEN = Path.of("src/test/resources/cmp_invoice.pdf");
@TempDir
Path tmp;
private static byte[] render(InvoicePdf.Invoice invoice) throws IOException {
var out = new ByteArrayOutputStream();
InvoicePdf.write(invoice, out);
return out.toByteArray();
}
@Test
void textContainsTheExpectedValuesInOrder() throws IOException {
String text = PdfAssert.text(render(INVOICE));
PdfAssert.assertContainsInOrder(text,
"Invoice 2026-0042", "Customer: Ada Example", "Dune", "25.00", "Neuromancer", "Total: 34.90 EUR");
}
@Test
void headerBlockMatchesTheTextBlock() throws IOException {
String expected = """
Bookshelf Billing
Invoice 2026-0042
Customer: Ada Example
""".replaceAll("\\s+", " ").trim();
assertTrue(PdfAssert.text(render(INVOICE)).startsWith(expected));
}
@Test
void structureIsAsExpected() throws IOException {
try (PdfDocument pdf = PdfAssert.open(render(INVOICE))) {
assertEquals(1, pdf.getNumberOfPages());
assertEquals("Invoice 2026-0042", pdf.getDocumentInfo().getTitle());
assertEquals(Set.of("Helvetica"), PdfAssert.fontNames(pdf, 1));
}
}
@Test
void matchesTheGoldenFile() throws Exception {
Path actual = tmp.resolve("invoice.pdf");
Files.write(actual, render(INVOICE));
if (Boolean.getBoolean("update.golden")) {
Files.copy(actual, GOLDEN, StandardCopyOption.REPLACE_EXISTING);
}
var compare = new CompareTool();
assertNull(compare.compareByContent(actual.toString(), GOLDEN.toString(), tmp.toString(), "diff_"));
assertNull(compare.compareDocumentInfo(actual.toString(), GOLDEN.toString()));
}
@Test
void looksTheSameWhenRendered() throws Exception {
assumeTrue(System.getenv("ITEXT_GS_EXEC") != null && System.getenv("ITEXT_MAGICK_COMPARE_EXEC") != null,
"Ghostscript and ImageMagick are not configured");
Path actual = tmp.resolve("invoice.pdf");
Files.write(actual, render(INVOICE));
assertNull(new CompareTool().compareVisually(actual.toString(), GOLDEN.toString(), tmp.toString(), "diff_"));
}
}
Reference: JUnit User Guide (, assumptions) and the CompareTool API.
Golden files
A golden file is a reviewed PDF stored next to the tests. Done well it catches every unintended change; done badly it produces a test that everyone regenerates without looking. The rules that keep it useful:
-
Store the golden file in the repository, in
src/test/resources, with thecmp_prefix, and treat a change to it like a change to source code: it is reviewed in the pull request. A PDF is binary, so describe in the commit message what changed, or attach the rendered image. -
Regenerate deliberately. The test above overwrites the reference only when the JVM is started with
-Dupdate.golden=true, and the pull request then shows exactly one changed binary file. Never regenerate on every run. -
Make the output deterministic. The comparison ignores dates and identifiers, but the document must not contain anything else that varies: no
LocalDate.now()in the text, no random invoice numbers, no list built from aHashMapwhose iteration order depends on the JVM. Inject aClockand the data into the generator, as the record input does. -
Check in the fonts the golden file was made with. An embedded font is part of the content; a system font that differs between a laptop and the CI image changes the file. The fonts page of this section shows how to load fonts from the classpath.
-
Do not golden-test signed documents. A signature contains a timestamp and a hash of the bytes, so it never repeats. Test the content before signing with a golden file and the signature with the validation API.
-
Keep the golden files small. One page with the features under test is better than a 40-page statement that changes whenever the layout does.
Choosing between text, structure and golden files
| Situation | Prefer | Why |
|---|---|---|
A business value (total, tax, customer name) must be right |
Text assertion |
Fails with the wrong value in the message and ignores layout. |
The document must carry metadata, outlines, attachments or tags |
Structure assertion |
Reads the property directly, with no rendering. |
The page must stay as designed, and the generator is under refactoring |
|
Catches any change to the content stream, including a moved cell. |
An upgrade of iText, of a font or of the JVM must not change the look |
|
Tolerates different internals, fails only on different pixels. |
Memory and speed
Measure before tuning. An invoice of one page is dominated by start-up costs (class loading, the first font parse), and those disappear in a long-running service. The sections below apply to the documents that stress the heap or the clock: statements with thousands of rows and services that render many small documents per second.
Immediate flush
A PdfDocument keeps every indirect object in memory until the file is closed, because a later object may refer to an
earlier one. The layout Document writes each page as soon as it is laid out when its immediateFlush constructor
argument is true, which is the default, and releases the memory of the page. The consequences are the ones to know:
-
Leave the default alone unless a second layout pass is needed.
new Document(pdf, pageSize, false)defers drawing untilclose()orrelayout()and keeps the whole document in memory. -
At the kernel level, call
PdfPage.flush()when a page is complete if you draw withPdfCanvasyourself. A flushed object can no longer be modified. -
Features that need the final state, such as "page X of Y", are implemented with placeholders filled at close, not by deferring flushing; see Page Events: Headers, Footers & Watermarks.
Reference: When is the content flushed to a PDF file by iText?. The program that compares both modes is on the Document Lifecycle page.
Large tables
Immediate flush releases pages, but one Table added to the document is a single element: until it is complete, all of
its cells are held in memory and laid out together. For a table of tens of thousands of rows that is the dominant cost.
The large-table mode (new Table(widths, true)) lets the program add the table to the document first and then hand over
rows in batches, calling flush() after each batch and complete() at the end; the rows already flushed are written to
the page and released.
The program below renders 50,000 rows in both modes to a null output stream, so that no disk time is measured. It takes
the mode (normal, large or fonts) as its second argument, so that the runs can be given different heaps.
package com.example.billing;
import com.itextpdf.io.font.PdfEncodings;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Cell;
import com.itextpdf.layout.element.Paragraph;
import com.itextpdf.layout.element.Table;
import com.itextpdf.layout.font.FontProvider;
import com.itextpdf.layout.font.FontSet;
import com.itextpdf.layout.properties.Property;
import com.itextpdf.layout.properties.UnitValue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.lang.management.MemoryType;
import java.nio.file.Files;
import java.nio.file.Path;
public final class PerformanceProbe {
private static final int ROWS = 50_000;
public static void main(String[] args) throws Exception {
Path font = Path.of(args.length > 0 ? args[0] : "DejaVuSans.ttf");
boolean large = args.length > 1 && args[1].equals("large");
if (args.length > 1 && args[1].equals("fonts")) {
fonts(font);
return;
}
System.gc();
ManagementFactory.getPlatformMXBeans(MemoryPoolMXBean.class).forEach(MemoryPoolMXBean::resetPeakUsage);
long start = System.nanoTime();
try (OutputStream out = OutputStream.nullOutputStream()) {
table(out, large);
}
long ms = (System.nanoTime() - start) / 1_000_000;
long peak = ManagementFactory.getPlatformMXBeans(MemoryPoolMXBean.class).stream()
.filter(p -> p.getType() == MemoryType.HEAP)
.mapToLong(p -> p.getPeakUsage().getUsed()).sum() / (1024 * 1024);
System.out.printf("%s table: %d rows, %d ms, peak heap pools about %d MB%n",
large ? "large" : "normal", ROWS, ms, peak);
}
static void table(OutputStream out, boolean large) throws IOException {
try (var pdf = new PdfDocument(new PdfWriter(out));
var document = new Document(pdf)) {
var table = new Table(UnitValue.createPercentArray(new float[] {1, 4, 2}), large)
.useAllAvailableWidth();
if (large) {
document.add(table);
}
for (int i = 1; i <= ROWS; i++) {
table.addCell(new Cell().add(new Paragraph(String.valueOf(i))));
table.addCell(new Cell().add(new Paragraph("Line item " + i)));
table.addCell(new Cell().add(new Paragraph(String.valueOf(i % 97) + ".00")));
if (large && i % 1000 == 0) {
table.flush();
}
}
if (large) {
table.complete();
} else {
document.add(table);
}
}
}
static void fonts(Path font) throws IOException {
byte[] bytes = Files.readAllBytes(font);
FontSet shared = new FontSet();
shared.addFont(bytes, PdfEncodings.IDENTITY_H, "DejaVu Sans");
int n = 200;
for (int round = 0; round < 2; round++) {
long t0 = System.nanoTime();
for (int i = 0; i < n; i++) {
var set = new FontSet();
set.addFont(bytes, PdfEncodings.IDENTITY_H, "DejaVu Sans");
render(new FontProvider(set));
}
long perDoc = (System.nanoTime() - t0) / 1_000_000;
long t1 = System.nanoTime();
for (int i = 0; i < n; i++) {
render(new FontProvider(shared));
}
long reuse = (System.nanoTime() - t1) / 1_000_000;
System.out.printf("round %d: new FontSet per document %d ms, shared FontSet %d ms (%d documents)%n",
round, perDoc, reuse, n);
}
}
static void render(FontProvider provider) throws IOException {
var out = new ByteArrayOutputStream();
try (var pdf = new PdfDocument(new PdfWriter(out));
var document = new Document(pdf)) {
document.setFontProvider(provider);
document.setProperty(Property.FONT, new String[] {"DejaVu Sans"});
document.add(new Paragraph("Invoice 2026-0042: total 34.90 EUR"));
}
}
}
Reference: Large tables and Table. The mode is explained in the large tables section of the Tables page.
Running it on the author’s machine (Java 21.0.10, the baseline of this page, iText 9.8.0, 50,000 rows with three cells each):
Heap limit (-Xmx) |
Normal table | Large table |
|---|---|---|
2 GB |
Completes in about 3.1 s; the heap pools peaked at roughly 1.2 GB. |
Completes in about 2.4 s; peak about 0.2 GB. |
128 MB |
|
Completes in about 2.4 s. |
48 MB |
|
Completes in about 2.5 s. |
These are single runs for illustration, without warm-up, and the peak is the sum of the JVM’s memory-pool peaks, an upper
bound. The qualitative result is what matters: elapsed time is of the same order, memory is not. Large mode gives a bounded heap
independent of the row count. Its price is that the table cannot change after a flush: the column widths, header and
footer are fixed when the table is added, a row cannot be revisited, and Table is not thread-safe, so rows must be
added from one thread.
A test should also prove that the two modes produce the same document, because the large mode is a different code path. The test below renders 3,000 rows both ways, compares the extracted text, asserts that the table really spans many pages and that the last row survived, and adds a deliberately generous time budget as a smoke test for gross regressions.
package com.example.billing;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Table;
import com.itextpdf.layout.properties.UnitValue;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LargeTableEquivalenceTest {
private static byte[] render(boolean large, int rows) throws IOException {
var out = new ByteArrayOutputStream();
try (var pdf = new PdfDocument(new PdfWriter(out));
var document = new Document(pdf)) {
var table = new Table(UnitValue.createPercentArray(new float[] {1, 4}), large).useAllAvailableWidth();
if (large) {
document.add(table);
}
for (int i = 1; i <= rows; i++) {
table.addCell("row " + i);
table.addCell("item " + i);
if (large && i % 500 == 0) {
table.flush();
}
}
if (large) {
table.complete();
} else {
document.add(table);
}
}
return out.toByteArray();
}
@Test
void largeModeProducesTheSameTextAsNormalMode() throws IOException {
byte[] normal = render(false, 3_000);
byte[] large = render(true, 3_000);
assertEquals(PdfAssert.text(normal), PdfAssert.text(large));
try (var pdf = PdfAssert.open(large)) {
assertTrue(pdf.getNumberOfPages() > 10);
}
assertTrue(PdfAssert.text(large).endsWith("row 3000 item 3000"));
}
@Test
void tenThousandRowsFinishWithinAGenerousBudget() {
assertTimeoutPreemptively(Duration.ofSeconds(30), () -> render(true, 10_000));
}
}
Reference: JUnit 5 User Guide (assertTimeoutPreemptively). A time
assertion is a smoke test and not a benchmark. It belongs on a CI machine you control, with a budget several times the
normal duration. For real measurements use a harness that handles warm-up, such as
JMH, and compare versions of your own code and of iText in the same run.
Making table rendering faster
Table layout cost grows with the number of cells because each cell has borders and, when the document is tagged, a tag structure. The iText team described how they cut the time for a 50,000-cell table from about five minutes to about seven seconds by avoiding repeated border-collapse calculations and unnecessary tagging work (How I Made PDF Table Rendering 95% Faster in an Afternoon). Two practical consequences for application code: stay on a recent 9.x release, because such improvements ship in patch and minor versions, and do not build in work that undoes them, such as setting individual borders on every cell when a table-level border does the same job. If a profile shows time inside border handling, try the table-level properties before reaching for renderers (see Renderers & Custom Layout).
Font caching and reusing the FontProvider
Parsing a font file is the most expensive thing a small document does. A TrueType file of 750 KB (the DejaVu Sans used below) is read, its tables are decoded and the glyph metrics are built every time a font is created from bytes or from a path. A service that renders an invoice per request and creates the font on every request pays this on every request. The rules, which the fonts page of this section explains in more depth:
-
A
PdfFontbelongs to exactly onePdfDocument; it cannot be cached across documents. -
The parsed font program, and the
FontSetthat holds one or more of them, is what can be shared. Build it once at start-up, never modify it afterwards, and create a newFontProviderfrom it for each document, because aFontProvideris also tied to one document. -
A
FontSetthat is only read after start-up can be used from several threads; the thread-safety test is on the fonts page.
The fonts option of the probe above (its fonts and render methods) renders 200 one-line documents in two ways:
creating a new FontSet from the font bytes for each document, and creating a new FontProvider on one shared FontSet.
Run it with java -cp ... com.example.billing.PerformanceProbe DejaVuSans.ttf fonts; the other two options, normal and
large, select the table mode of the previous section.
On the same machine and Java 21, the second round (after warm-up) took about 181 ms for 200 documents with a FontSet
per document and about 63 ms with the shared one, which is roughly three times faster, and the first round, which
includes JIT warm-up, was 339 ms against 84 ms. The absolute numbers depend on the font (a CJK font of several megabytes widens the gap by
orders of magnitude), but the direction does not.
Reference: FontSet and How to embed a font only partially. Subsetting makes the output smaller and is on by default; it does not make parsing faster, because the whole file must be read. The trade-off for very large fonts is to embed them fully only when the text editing downstream requires it, which costs file size and not CPU. See Fonts & Unicode.
Other levers
-
Write to the destination directly. Pass the servlet output stream, or a stream to object storage, to the
PdfWriter. Buffering in aByteArrayOutputStreamis right for a small invoice that is also stored; for large reports it doubles the memory. Spring Boot streaming is covered in Getting Started with Spring Boot. -
Keep compression on. Compression is on by default for streams.
WriterProperties.setFullCompressionMode(true)also compresses the cross-reference table and objects into object streams, which shrinks the file and costs a little CPU. Do not turn compression off to save time, because the saving is small and the file is much larger. -
Avoid
relayout()andimmediateFlush=falseunless required; they are the main way to make a document quadratic in memory. -
Do not parse the same image repeatedly. Create the
ImageDataonce and build anImagefrom it per use. See Colours, Images & SVG. -
Bound concurrency. Rendering is CPU-bound and allocates heavily. A fixed pool sized to the number of cores, or Java 21 virtual threads with a semaphore, protects the heap better than one thread per request.
GraalVM native image
A native image is a standalone binary compiled ahead of time. It starts in milliseconds and uses less memory at rest, which suits a container or function that renders occasional PDFs. iText Core supports GraalVM native image since version 8.0.4 (May 2024), and the pdfHTML add-on since pdfHTML 6.1.0, shipped with iText Suite 9.1. The iText modules carry their own reachability metadata (the reflection and resource configuration that a native build needs), and the iText project tests that they work, which puts iText Core in the highest rating of GraalVM’s list of libraries tested with native image. Reference: The Road to GraalVM Native Image Compatibility and the native image manual.
What the iText team documents as limits, and what to check in your own build:
-
Security providers. A provider cannot be registered at run time in a native image. Since iText Core 9.1.0 the
SecurityProviderProxyclass handles registration at build time when the build runs under GraalVM, so the Bouncy Castle adapter needs no customFeatureclass, which the article shows for earlier versions. Bouncy Castle FIPS could not be built the same way; GraalVM recommends another provider for FIPS applications. -
AWT.
java.awtclasses such asToolkitare not available, so do not pass ajava.awt.ImagetoImageDataFactory; read the image from bytes or a path. -
Resources. Fonts, ICC profiles and templates your code loads from the classpath must be listed in the reachability metadata of your application. iText’s own resources are covered by its jars.
For a Spring Boot 4 service the build is the one documented by Spring: the GraalVM Native Build Tools plugin, activated
with the native profile that the Spring Boot parent POM defines. Spring’s own ahead-of-time processing runs as part
of that build and is independent of iText.
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
# Requires a GraalVM JDK supported by your Spring Boot version, with native-image on the PATH
mvn -Pnative native:compile
./target/billing
References: Spring Boot native image support and the GraalVM reachability metadata repository. These two commands were not executed here, because GraalVM was not installed on the test machine; the plugin, the profile and the iText compatibility statements come from the sources above. A native build is slow and memory-hungry, and it does not run the JIT, so throughput at steady state can be lower than on the JVM for CPU-heavy jobs. Choose it for start-up and footprint, run the golden-file tests against the native binary in CI (the document produced must be the same), and keep a JVM profile available.
Putting it together for Bookshelf Billing
A reasonable test set for the invoice service:
-
A text assertion for every business value (the invoice number, customer name, lines and total).
-
A structure assertion for metadata, page count and fonts.
-
One
compareByContentgolden file per template, regenerated deliberately. -
One
compareVisuallygolden file per template, run only where Ghostscript and ImageMagick are installed. -
An equivalence test between normal and large table mode for statements.
-
A smoke test with a generous time budget, and a JMH benchmark outside the build for tuning.
-
For every signed or encrypted output, a test of the unsigned content and a separate validation test.
Where the older books are outdated (O28)
The classic iText book, Bruno Lowagie’s iText in Action (Manning, 2006,
consulted for this section and not reproduced; see the section
bibliography), targets Java 1.4 and its examples are written in that style: no generics, raw collections and
iteration with an Iterator, anonymous classes, resource cleanup in finally blocks and String concatenation for
multi-line text. The examples on this page use the idioms of Java 21 (an LTS release) instead:
-
Records for input data (
InvoiceandLineabove), replacing a bean with fields, accessors andequals. -
try-with-resources for thePdfDocumentandDocument, which close in reverse order on every path, including failures, and replace thefinallyblock withclose()calls. -
Text blocks for multi-line expectations, as in
headerBlockMatchesTheTextBlock. -
varfor local variables whose type is obvious from the right-hand side, such asvar compare = new CompareTool(). -
Generics and the stream API where they shorten the code, such as the
LinkedHashSet<String>of font names and thereducethat sums the invoice lines. -
JUnit 5 annotations (
@Test,@TempDir) andassumeTrueinstead of the JUnit 3 and 4 base classes.
A Java 1.4 program has no records, text blocks or JUnit 5, so the book offers no model for a test suite; the approach on this page, built from the official documentation, is the current one.
References
-
iText Java BUILDING.md (Ghostscript and ImageMagick configuration)