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.

PdfTextExtractor and plain JUnit assertions.

Structure

Page count, metadata, fonts, form fields, outlines, attachments and tags are what the code intended.

The kernel API on a PdfDocument opened from the produced bytes.

Content streams

The drawing operators, resources and objects equal those of a reviewed reference file.

CompareTool.compareByContent against a golden file.

Pixels

The rendered pages look the same, even if the internal objects differ.

CompareTool.compareVisually, which needs Ghostscript and ImageMagick.

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 compareByContent also tries to render both files to produce difference images, so it calls Ghostscript. When Ghostscript is not configured the call throws a RuntimeException that names the ITEXT_GS_EXEC environment 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, compareLinkAnnotations and compareTagStructures, 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, and compareByContent then reads it from there. In 9.8.0 no file is left on disk after the document is closed, so use a plain PdfWriter when 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 the cmp_ 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 a HashMap whose iteration order depends on the JVM. Inject a Clock and 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

compareByContent golden file

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

compareVisually

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 until close() or relayout() and keeps the whole document in memory.

  • At the kernel level, call PdfPage.flush() when a page is complete if you draw with PdfCanvas yourself. 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

OutOfMemoryError.

Completes in about 2.4 s.

48 MB

OutOfMemoryError.

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 PdfFont belongs to exactly one PdfDocument; it cannot be cached across documents.

  • The parsed font program, and the FontSet that holds one or more of them, is what can be shared. Build it once at start-up, never modify it afterwards, and create a new FontProvider from it for each document, because a FontProvider is also tied to one document.

  • A FontSet that 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 a ByteArrayOutputStream is 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() and immediateFlush=false unless required; they are the main way to make a document quadratic in memory.

  • Do not parse the same image repeatedly. Create the ImageData once and build an Image from 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 SecurityProviderProxy class handles registration at build time when the build runs under GraalVM, so the Bouncy Castle adapter needs no custom Feature class, 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.awt classes such as Toolkit are not available, so do not pass a java.awt.Image to ImageDataFactory; 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:

  1. A text assertion for every business value (the invoice number, customer name, lines and total).

  2. A structure assertion for metadata, page count and fonts.

  3. One compareByContent golden file per template, regenerated deliberately.

  4. One compareVisually golden file per template, run only where Ghostscript and ImageMagick are installed.

  5. An equivalence test between normal and large table mode for statements.

  6. A smoke test with a generous time budget, and a JMH benchmark outside the build for tuning.

  7. 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 (Invoice and Line above), replacing a bean with fields, accessors and equals.

  • try-with-resources for the PdfDocument and Document, which close in reverse order on every path, including failures, and replace the finally block with close() calls.

  • Text blocks for multi-line expectations, as in headerBlockMatchesTheTextBlock.

  • var for local variables whose type is obvious from the right-hand side, such as var compare = new CompareTool().

  • Generics and the stream API where they shorten the code, such as the LinkedHashSet<String> of font names and the reduce that sums the invoice lines.

  • JUnit 5 annotations (@Test, @TempDir) and assumeTrue instead 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.