Testing & Production Practices
|
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 service fails in two quiet ways. The output can be wrong while the file still opens: a missing row, a font that was
not embedded, a signature that no longer matches the bytes. And the service can be right on a laptop and wrong in a
container: no fonts, no display, a timestamp authority that never answers, a heap that fills when ten large reports run at
once. This page is the checklist for both, for the Bookshelf Billing service. The first half is about tests that run in
CI: text assertions with Apache PDFBox 3.0.8 and iText 9.8.0, a pointer to CompareTool, PDF/A validation with veraPDF
and signature checks. The second half is about production: memory, concurrency, thread safety, containers, caching,
timeouts on certificate and timestamp servers, secrets for signing keys, metrics and licences.
Versions are stated as of 2026-10-09: iText Core 9.8.0, Apache PDFBox 3.0.8, veraPDF 1.30.3, Micrometer 1.17.1 and JUnit
5.14.4, all on Java 21 (the code was compiled with --release 21 on a newer JDK). Everything in the Java listings was
compiled and run as a throwaway Maven project against these versions, and every test passed. One thing was not run, and is marked where it appears: the veraPDF command-line step of the GitHub Actions workflow,
because Docker was not available. Pieces that other pages of this section already cover are linked and not
repeated: golden files, CompareTool and iText performance in
Testing & Performance with iText, large JasperReports
runs in Large Reports & Performance, and
untrusted templates in Security Hardening.
The project
One Maven project holds every example on the page. itext-core is a POM aggregator, so it is declared with
<type>pom</type>; the Bouncy Castle adapter is needed for the signature tests. PDFBox, veraPDF and JUnit are test
dependencies, while Micrometer is used by the production code. The Spring Boot parent is left out on purpose: nothing
here needs Spring, and the classes drop into a Spring Boot 4 service unchanged.
<dependencies>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itext-core</artifactId>
<version>9.8.0</version>
<type>pom</type>
</dependency>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>bouncy-castle-adapter</artifactId>
<version>9.8.0</version>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
<version>1.17.1</version>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.8</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.verapdf</groupId>
<artifactId>validation-model</artifactId>
<version>1.30.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.14.4</version>
<scope>test</scope>
</dependency>
</dependencies>
The class under test is a small stateless generator. It takes an OutputStream, which is what makes it testable, and it
creates its own PdfDocument on every call, which is what makes it safe to call from several threads (see
Thread safety).
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.AreaBreak;
import com.itextpdf.layout.element.Paragraph;
import java.io.OutputStream;
/** Stateless generator: every call builds its own PdfDocument, so it can be called from any thread. */
public final class InvoicePdf {
private InvoicePdf() {
}
public static void write(String number, String customer, int pages, OutputStream out) {
try (PdfDocument pdf = new PdfDocument(new PdfWriter(out));
Document document = new Document(pdf)) {
pdf.getDocumentInfo().setTitle("Invoice " + number);
for (int page = 1; page <= pages; page++) {
if (page > 1) {
document.add(new AreaBreak());
}
document.add(new Paragraph("Bookshelf Billing - invoice " + number));
document.add(new Paragraph("Customer: " + customer));
document.add(new Paragraph("Total: 42.50 EUR (page " + page + ")"));
}
}
}
}
Official documentation: Document (iText Javadoc), the class this generator is built from.
Testing PDFs
A PDF test pyramid has four levels, and the cheap ones come first. Values and structure are asserted on extracted text and on the document model. Content streams are compared against a reviewed golden file. Pixels are compared only when rendering itself matters. Standards conformance (PDF/A, PDF/UA) and signatures are separate checks, because a file can pass every text assertion and still be non-conformant or tampered with.
Text assertions with PDFBox
PDFBox is a good second opinion for a test suite even when iText produced the file: it parses the bytes independently, so
a bug in how iText wrote an object is more likely to surface as a parse problem than when iText reads its own output.
The helper below loads the bytes with Loader.loadPDF (the PDFBox 3 replacement for PDDocument.load), extracts text
with PDFTextStripper sorted by position and collapses whitespace, so that a changed margin or a different line break
does not fail a test about values.
package com.example.billing;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
/** Test helpers that turn PDF bytes into values a unit test can assert on, using Apache PDFBox. */
final class PdfAssert {
private PdfAssert() {
}
static String text(byte[] pdf) throws IOException {
try (PDDocument document = Loader.loadPDF(pdf)) {
PDFTextStripper stripper = new PDFTextStripper();
stripper.setSortByPosition(true);
return stripper.getText(document).replaceAll("\\s+", " ").strip();
}
}
static int pageCount(byte[] pdf) throws IOException {
try (PDDocument document = Loader.loadPDF(pdf)) {
return document.getNumberOfPages();
}
}
static void assertContainsInOrder(byte[] pdf, String... fragments) throws IOException {
String text = text(pdf);
int from = 0;
for (String fragment : fragments) {
int found = text.indexOf(fragment, from);
if (found < 0) {
fail("'" + fragment + "' not found after position " + from + " in: " + text);
}
from = found + fragment.length();
}
assertTrue(from > 0);
}
}
Official documentation: PDFTextStripper
and the PDFBox 3.0 migration guide, which explains the Loader class.
Text assertions with iText
The same assertions work with iText itself. PdfTextExtractor.getTextFromPage reads one page, and the page count comes
straight from the PdfDocument. Together with the PDFBox helper, the test below checks values and order with one library
and page structure with the other. Text extraction is limited to what the PDF says: a scanned page has no text, and the
order of fragments depends on how the generator drew them, as explained in
Text Extraction & Parsing.
package com.example.billing;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InvoiceTextTest {
private static byte[] invoice(int pages) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
InvoicePdf.write("BB-2026-0001", "Ada Lovelace", pages, out);
return out.toByteArray();
}
@Test
void pdfBoxSeesValuesInOrder() throws IOException {
byte[] pdf = invoice(1);
PdfAssert.assertContainsInOrder(pdf, "invoice BB-2026-0001", "Customer: Ada Lovelace", "Total: 42.50 EUR");
assertEquals(1, PdfAssert.pageCount(pdf));
}
@Test
void iTextSeesTheSamePages() throws IOException {
byte[] pdf = invoice(2);
try (PdfDocument document = new PdfDocument(new PdfReader(new ByteArrayInputStream(pdf)))) {
assertEquals(2, document.getNumberOfPages());
String second = PdfTextExtractor.getTextFromPage(document.getPage(2));
assertTrue(second.contains("page 2"), second);
}
}
}
Official documentation: PdfTextExtractor (iText Javadoc).
CompareTool and golden files
CompareTool compares two PDFs object by object (compareByContent) or page by page as images (compareVisually,
which needs Ghostscript and ImageMagick). It is the right tool for a layout regression, and the wrong one for a document
that contains the current date or a random identifier. The page
Testing & Performance with iText shows both modes, how to
exclude volatile entries and how to regenerate golden files, so that is not repeated here. What matters in CI is where the
failure output goes: CompareTool writes its difference files into a folder you choose. The project of this page has no
CompareTool test of its own, but if you add the ones from that page and point their output folder at target/pdf-out,
the workflow below uploads that folder as an artifact when the build fails, so a reviewer can open the two files side by
side without reproducing the build.
Official documentation: CompareTool (iText Javadoc).
Standards conformance with veraPDF
veraPDF is the open-source reference validator for PDF/A and PDF/UA. It is not a unit-test assertion library; it is a
validator with a rule set, and it tells you which clause of the standard a file breaks. Two ways to use it fit a CI
pipeline. The Java library runs inside the test suite, so one mvn verify covers it; the command line (or its Docker
image) validates files produced by anything, including files from JasperReports or a third party.
The test below uses the Java library. It builds an invoice with iText as PDF/A-2B, with the font embedded and the sRGB output intent, and expects it to be compliant. The second test validates the plain invoice from the generator above as PDF/A-2B and expects a failure: it has no XMP metadata, no embedded font and a device colour space without an output intent. That second test is a useful guard in the other direction, because it proves the validator is able to say "no". Both were run: the first passed, and the second reported rules 6.6.2.1, 6.2.11.4.1 and 6.2.4.3 of ISO 19005-2.
package com.example.billing;
import com.itextpdf.io.font.PdfEncodings;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.font.PdfFontFactory.EmbeddingStrategy;
import com.itextpdf.kernel.pdf.PdfAConformance;
import com.itextpdf.kernel.pdf.PdfOutputIntent;
import com.itextpdf.kernel.pdf.PdfString;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Paragraph;
import com.itextpdf.pdfa.PdfADocument;
import java.awt.color.ColorSpace;
import java.awt.color.ICC_Profile;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
/** PDF/A-2B invoice with an embedded font and the sRGB profile of the JDK (use a licensed profile in production). */
public final class ArchivalInvoicePdf {
private ArchivalInvoicePdf() {
}
public static void write(String number, OutputStream out) throws IOException {
byte[] icc = ICC_Profile.getInstance(ColorSpace.CS_sRGB).getData();
PdfOutputIntent intent = new PdfOutputIntent("Custom", "", "http://www.color.org",
"sRGB IEC61966-2.1", new ByteArrayInputStream(icc));
byte[] fontBytes;
try (InputStream in = ArchivalInvoicePdf.class.getResourceAsStream("/fonts/DejaVuSans.ttf")) {
fontBytes = in.readAllBytes();
}
try (PdfADocument pdf = new PdfADocument(new PdfWriter(out), PdfAConformance.PDF_A_2B, intent);
Document document = new Document(pdf)) {
pdf.getDocumentInfo().setTitle("Invoice " + number);
pdf.getCatalog().setLang(new PdfString("en-US"));
PdfFont font = PdfFontFactory.createFont(fontBytes, PdfEncodings.IDENTITY_H,
EmbeddingStrategy.FORCE_EMBEDDED);
document.setFont(font);
document.add(new Paragraph("Bookshelf Billing - invoice " + number));
}
}
}
The font is a TrueType file on the classpath under /fonts/DejaVuSans.ttf; any font whose licence allows embedding
works, and the DejaVu fonts of the fonts-dejavu-core package are one. The ICC profile is the sRGB profile that ships
with the JDK, which is fine for a test; use a profile with a licence you have checked in production. PDF/A with iText is
explained in PDF/A & PDF/UA with iText.
package com.example.billing;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.verapdf.core.VeraPDFException;
import org.verapdf.gf.foundry.VeraGreenfieldFoundryProvider;
import org.verapdf.pdfa.Foundries;
import org.verapdf.pdfa.PDFAParser;
import org.verapdf.pdfa.PDFAValidator;
import org.verapdf.pdfa.flavours.PDFAFlavour;
import org.verapdf.pdfa.results.TestAssertion;
import org.verapdf.pdfa.results.ValidationResult;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PdfAConformanceTest {
@BeforeAll
static void initialiseVeraPdf() {
VeraGreenfieldFoundryProvider.initialise();
}
private static ValidationResult validate(byte[] pdf) throws IOException, VeraPDFException {
try (PDFAParser parser = Foundries.defaultInstance()
.createParser(new ByteArrayInputStream(pdf), PDFAFlavour.PDFA_2_B)) {
PDFAValidator validator = Foundries.defaultInstance().createValidator(PDFAFlavour.PDFA_2_B, false);
return validator.validate(parser);
}
}
@Test
void archivalInvoiceIsPdfA2b() throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
ArchivalInvoicePdf.write("BB-2026-0001", out);
// Keep the file so that a CI step can validate it again with the veraPDF command line
Path folder = Files.createDirectories(Path.of("target", "pdf-out"));
Files.write(folder.resolve("archival-invoice.pdf"), out.toByteArray());
ValidationResult result = validate(out.toByteArray());
assertTrue(result.isCompliant(), () -> "Failed rules: " + result.getTestAssertions());
}
@Test
void plainInvoiceIsNotPdfA() throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
InvoicePdf.write("BB-2026-0001", "Ada Lovelace", 1, out);
ValidationResult result = validate(out.toByteArray());
assertFalse(result.isCompliant());
List<TestAssertion> failed = result.getTestAssertions();
failed.stream().limit(3).forEach(a -> System.out.println(
a.getRuleId().getClause() + " test " + a.getRuleId().getTestNumber() + ": " + a.getMessage()));
}
}
The Java library is initialised once with VeraGreenfieldFoundryProvider.initialise(), and the flavour (PDFA_2_B)
selects the rule set. It is dual licensed under GPLv3+ and MPLv2+, which is why the dependency is declared with test
scope here: it never ships with the service.
Official documentation: the veraPDF developer guide (library usage) and the veraPDF documentation. The standards themselves are summarised in PDF Standards & Conformance.
veraPDF in GitHub Actions
The workflow below runs the whole suite, validates the file written by the test with the veraPDF command line from its
Docker image, and uploads the output folder if anything failed. The docker run form with a mounted /data folder is
the one shown on the veraPDF CLI image page; the flags are those of the veraPDF
command line, documented on the CLI page (check --help of the version you pin). This
workflow was not run: Docker was not available on the machine used to prepare this page, so treat the verapdf/cli step
as a starting point and pin an image tag after trying it once.
name: pdf-quality
on:
pull_request:
push:
branches: [main]
jobs:
pdf:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
cache: maven
- name: Tests (text, veraPDF library, signatures)
run: mvn -B verify
- name: Validate the archival invoice with the veraPDF CLI
run: >
docker run --rm -v "$PWD/target/pdf-out:/data" verapdf/cli
--flavour 2b --format text /data/archival-invoice.pdf
- name: Keep the produced PDFs when something failed
if: failure()
uses: actions/upload-artifact@v4
with:
name: pdf-output
path: target/pdf-out
Official documentation: Workflow syntax for GitHub Actions, actions/setup-java and actions/upload-artifact.
Signature validation in tests
A signed PDF needs two assertions: the signature is valid when the file leaves the service, and it stops being valid if
a single signed byte changes. The first catches a signing bug, the second proves the test can fail. The class below
generates a throwaway self-signed certificate in memory with Bouncy Castle (nothing is written to disk and nothing is
trusted), signs the generator’s output with iText PdfSigner, and reads it back with SignatureUtil.
package com.example.billing;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import java.math.BigInteger;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Security;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
/** A throwaway self-signed signer generated in memory for tests. Nothing is stored and nothing is trusted. */
record TestPki(KeyPair keys, X509Certificate certificate) {
static {
Security.addProvider(new BouncyCastleProvider());
}
static TestPki selfSigned(String commonName) throws GeneralSecurityException,
org.bouncycastle.operator.OperatorCreationException {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(3072);
KeyPair keys = generator.generateKeyPair();
X500Name name = new X500Name("CN=" + commonName + ",O=Bookshelf Test,C=ES");
Instant now = Instant.now();
X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(name,
BigInteger.valueOf(now.toEpochMilli()), Date.from(now.minus(Duration.ofMinutes(5))),
Date.from(now.plus(Duration.ofDays(1))), name, keys.getPublic());
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(keys.getPrivate());
X509Certificate certificate = new JcaX509CertificateConverter().getCertificate(builder.build(signer));
return new TestPki(keys, certificate);
}
}
package com.example.billing;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.StampingProperties;
import com.itextpdf.signatures.BouncyCastleDigest;
import com.itextpdf.signatures.PdfPKCS7;
import com.itextpdf.signatures.PdfSigner;
import com.itextpdf.signatures.PrivateKeySignature;
import com.itextpdf.signatures.SignatureUtil;
import com.itextpdf.signatures.SignerProperties;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.cert.Certificate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SignatureTest {
private static byte[] sign(byte[] unsigned, TestPki pki) throws Exception {
ByteArrayOutputStream signed = new ByteArrayOutputStream();
PdfSigner signer = new PdfSigner(new PdfReader(new ByteArrayInputStream(unsigned)), signed,
new StampingProperties());
signer.setSignerProperties(new SignerProperties().setFieldName("billing-signature"));
PrivateKeySignature signature = new PrivateKeySignature(pki.keys().getPrivate(), "SHA256", "BC");
signer.signDetached(new BouncyCastleDigest(), signature, new Certificate[] {pki.certificate()},
null, null, null, 0, PdfSigner.CryptoStandard.CMS);
return signed.toByteArray();
}
private static byte[] invoice() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
InvoicePdf.write("BB-2026-0001", "Ada Lovelace", 1, out);
return out.toByteArray();
}
private static boolean integrityHolds(byte[] pdf) throws Exception {
try (PdfDocument document = new PdfDocument(new PdfReader(new ByteArrayInputStream(pdf)))) {
SignatureUtil util = new SignatureUtil(document);
List<String> names = util.getSignatureNames();
assertEquals(List.of("billing-signature"), names);
assertTrue(util.signatureCoversWholeDocument("billing-signature"));
PdfPKCS7 pkcs7 = util.readSignatureData("billing-signature");
return pkcs7.verifySignatureIntegrityAndAuthenticity();
}
}
@Test
void signedInvoiceVerifies() throws Exception {
byte[] signed = sign(invoice(), TestPki.selfSigned("Bookshelf Billing Test Signer"));
assertTrue(integrityHolds(signed));
}
@Test
void editingASignedByteBreaksTheSignature() throws Exception {
byte[] signed = sign(invoice(), TestPki.selfSigned("Bookshelf Billing Test Signer"));
// Same-length edit of the document title, which is stored as a plain string inside the signed range
String text = new String(signed, StandardCharsets.ISO_8859_1);
assertTrue(text.contains("(Invoice BB-2026-0001)"));
signed = text.replace("(Invoice BB-2026-0001)", "(Invoice BB-2026-0002)").getBytes(StandardCharsets.ISO_8859_1);
assertFalse(integrityHolds(signed));
}
}
verifySignatureIntegrityAndAuthenticity checks that the CMS container matches the signed bytes and that the signer’s
key signed it. It does not build a trust path, check revocation or apply a policy; that is the job of the validators
described in
Signature Validation & Long-Term Validity,
which also shows iText’s SignatureValidator and EU DSS. For a unit test the integrity check is the right size. For an
acceptance test against a real qualified certificate, run a real validator in a separate, slower job, and never put that
certificate in the repository. Signing itself is covered in
Signing PDFs with iText and
PAdES Baseline Levels.
Official documentation: SignatureUtil and PdfSigner (iText Javadoc).
Production practices
The diagram is the shape of a safe request path: bound the work, render with a fresh document, call out to certificate servers with deadlines, and measure every step. The sections below follow it.
Memory and streaming
A PDF writer holds the objects of the document it has not yet flushed, and a report holds its rows. The heap needed by one request is therefore roughly the size of the result times a factor, and the number of requests in flight multiplies it. Three habits keep this predictable.
-
Write to an
OutputStream, never to a path, in the generator. A controller can then choose between aByteArrayOutputStream(small documents, known size, a clean error status if rendering fails) and the response stream (large documents). Streaming a large document means the status line has already been sent when an exception is thrown, so the client receives a truncated PDF with a 200 status; render to a temporary file or a bounded buffer first if that is not acceptable. Controllers are shown in Getting Started with Spring Boot. -
Bound the number of concurrent renderings. The
Semaphorein the service below is the simplest bulkhead: a request that cannot get a permit within a short wait is rejected, which is better than letting the JVM run out of memory for everybody. -
For very large reports, flush early (immediate flush and large tables in iText, covered in Testing & Performance with iText) or use a JasperReports virtualizer, covered in Large Reports & Performance.
Size the container with this in mind: set -XX:MaxRAMPercentage rather than a fixed -Xmx, so the heap follows the
container limit, and leave headroom for off-heap use (fonts, native memory of the image libraries, thread stacks).
Official documentation: the JasperReports virtualizer sample and the PdfWriter Javadoc.
Observability with Micrometer
The service below combines the bulkhead and the metrics. A Timer tagged with the outcome gives the count, the total
time and the maximum of both successes and failures; a Counter counts requests refused by the bulkhead; a
DistributionSummary records the size of each PDF in bytes, which is the cheapest early warning that a template
started to include something huge. Tags stay low-cardinality on purpose: the outcome is a tag, the invoice number or the
customer is not.
package com.example.billing;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.io.ByteArrayOutputStream;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
/** Bounds concurrent renderings (memory) and records timers, counters and the size of every PDF. */
public class MeteredInvoiceService {
private final Semaphore permits;
private final long waitMillis;
private final MeterRegistry registry;
private final Counter rejected;
private final DistributionSummary size;
public MeteredInvoiceService(MeterRegistry registry, int maxConcurrent, long waitMillis) {
this.registry = registry;
this.permits = new Semaphore(maxConcurrent);
this.waitMillis = waitMillis;
this.rejected = Counter.builder("billing.pdf.rejected")
.description("Requests refused because all rendering permits were busy")
.register(registry);
this.size = DistributionSummary.builder("billing.pdf.size")
.baseUnit("bytes").description("Size of generated PDFs").register(registry);
}
public byte[] render(String number, String customer, int pages) throws InterruptedException {
if (!permits.tryAcquire(waitMillis, TimeUnit.MILLISECONDS)) {
rejected.increment();
throw new IllegalStateException("Too many PDFs are being generated, try again later");
}
Timer.Sample sample = Timer.start(registry);
String outcome = "error";
try {
ByteArrayOutputStream out = new ByteArrayOutputStream();
InvoicePdf.write(number, customer, pages, out);
outcome = "ok";
size.record(out.size());
return out.toByteArray();
} finally {
// Tags are low-cardinality on purpose: never use the invoice number or the customer as a tag
sample.stop(Timer.builder("billing.pdf.generate").tag("outcome", outcome)
.description("Time spent generating an invoice PDF").register(registry));
permits.release();
}
}
}
In Spring Boot the MeterRegistry is injected and exported by Actuator
(Metrics and Observability); the test uses a
SimpleMeterRegistry.
package com.example.billing;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MeteredInvoiceServiceTest {
@Test
void recordsTimerAndSize() throws Exception {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
MeteredInvoiceService service = new MeteredInvoiceService(registry, 2, 1_000);
byte[] pdf = service.render("BB-2026-0001", "Ada Lovelace", 1);
assertEquals(1, registry.get("billing.pdf.generate").tag("outcome", "ok").timer().count());
assertEquals(pdf.length, registry.get("billing.pdf.size").summary().totalAmount(), 0.5);
assertEquals(0, registry.get("billing.pdf.rejected").counter().count());
}
@Test
void generatesConcurrentlyWithoutMixingDocuments() throws Exception {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
MeteredInvoiceService service = new MeteredInvoiceService(registry, 4, 10_000);
try (ExecutorService pool = Executors.newFixedThreadPool(8)) {
List<Future<String>> results = new ArrayList<>();
for (int i = 0; i < 64; i++) {
String number = "BB-2026-%04d".formatted(i);
Callable<String> task = () -> {
byte[] pdf = service.render(number, "Customer " + number, 1);
return PdfAssert.text(pdf);
};
results.add(pool.submit(task));
}
for (int i = 0; i < results.size(); i++) {
String number = "BB-2026-%04d".formatted(i);
String text = results.get(i).get();
assertTrue(text.contains("invoice " + number) && text.contains("Customer " + number), text);
}
}
assertEquals(64, registry.get("billing.pdf.generate").tag("outcome", "ok").timer().count());
}
@Test
void rejectsWhenAllPermitsAreBusy() throws Exception {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
MeteredInvoiceService service = new MeteredInvoiceService(registry, 0, 50);
try {
service.render("BB-2026-0001", "Ada Lovelace", 1);
} catch (IllegalStateException expected) {
assertEquals(1, registry.get("billing.pdf.rejected").counter().count());
return;
}
throw new AssertionError("expected a rejection");
}
}
Official documentation: Micrometer timers, counters and distribution summaries, and the Spring Boot metrics reference.
Thread safety
The rule that holds across the libraries of this section: documents are not thread safe, read-only configuration is.
| Safe to share between threads | Create per request |
|---|---|
A compiled JasperReports |
|
An iText |
|
ZXing writers (stateless encoders) |
The iText object built from the encoded matrix |
|
Anything that holds a |
A PdfFont belongs to the document it was created in, and a FontProvider caches the PdfFont objects it creates, so
neither is shared: cache the font bytes or the FontSet, and build a new FontProvider over that FontSet per document.
The second test of the previous section (generatesConcurrentlyWithoutMixingDocuments) submits 64 invoices to a pool
of eight threads through the bulkhead and checks that each PDF contains its own number and customer and nobody
else’s; it passed. It would not catch every race, but it catches the most common bug: a shared mutable field in the
generator.
Official documentation: the
JasperCompileManager
Javadoc for compiled reports, and the fonts page of this
section for FontProvider.
Fonts in containers and headless AWT
Both subjects are covered, with a Dockerfile, in
Getting Started with Spring Boot and
Fonts & Internationalization. The summary is short.
Run the JVM with -Djava.awt.headless=true, because JasperReports and some iText code paths use Java 2D and a server has
no display. Install fontconfig and fonts-dejavu-core in a slim Debian-based image, or better embed the fonts you need
as files and register them, so the output does not depend on the image. Test it where it runs: a CI job that builds the
image and renders one invoice with an accented name and a non-Latin character catches a missing font on the first
pull request, instead of in production. The fc-list command inside the image shows which fonts fontconfig sees.
Official documentation: the Java SE GraphicsEnvironment Javadoc describes headless mode.
Caching compiled reports and fonts
Compiling a JRXML is by far the most expensive step of a JasperReports run, and the compiled JasperReport is
immutable and shareable, so compile at build time or once at start-up and keep the object in a map. Fonts are the second
item: read font files into memory once, and build the FontSet once. Both techniques, with measurements, are on the
Large Reports & Performance page (compiled
reports) and the iText testing and performance page
(fonts), so only the rules are repeated here: key the cache by template name and version, never by user input; do not
cache a JasperPrint; and invalidate by redeploying, not by watching files, in a service that runs in a container.
Timeouts on TSA, OCSP and CRL calls
A signature at level B-T or higher calls a timestamp authority (TSA), and levels B-LT and B-LTA also call OCSP responders and download CRLs. Every one of these is a network call to a server you do not control, made while a request thread, a permit of your bulkhead and a document in memory are waiting. Without a timeout, one slow server stalls the whole signing path.
iText gives you the hooks, and they are not all the same:
-
TSAClientBouncyCastlehas no timeout setting. Its protected methodgetTSAResponse(byte[])performs the HTTP exchange, so a subclass can replace the transport withjava.net.http.HttpClient, which has a connect timeout and a per-request timeout. -
OcspClientBouncyCastleaccepts anIAdvancedResourceRetrieverthroughwithResourceRetriever. TheDefaultResourceRetrieverhassetConnectTimeout,setReadTimeoutandsetResourceSizeByteLimit, and the last one also caps what a hostile responder can make you read. -
CrlClientOnlinehassetConnectionTimeout(int)and also takes a resource retriever.
package com.example.billing;
import com.itextpdf.signatures.TSAClientBouncyCastle;
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.time.Duration;
/** TSAClientBouncyCastle whose HTTP exchange has a connect timeout and an overall deadline. */
public class TimeoutTsaClient extends TSAClientBouncyCastle {
private final HttpClient http;
private final Duration deadline;
public TimeoutTsaClient(String url, Duration connectTimeout, Duration deadline) {
super(url);
this.http = HttpClient.newBuilder().connectTimeout(connectTimeout).build();
this.deadline = deadline;
}
@Override
protected byte[] getTSAResponse(byte[] requestBytes) throws IOException {
HttpRequest request = HttpRequest.newBuilder(URI.create(tsaURL))
.timeout(deadline)
.header("Content-Type", "application/timestamp-query")
.POST(HttpRequest.BodyPublishers.ofByteArray(requestBytes))
.build();
try {
HttpResponse<byte[]> response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
throw new IOException("TSA answered HTTP " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while waiting for the TSA", e);
}
}
}
The test starts a local HTTP server that accepts a request and never answers, calls the clients with short timeouts and
asserts that they give up within five seconds. It ran against the local stalled server only; no real TSA or OCSP responder
is contacted. The OCSP client returns null when the call fails, which is the behaviour to design for: the signer then
decides whether a missing response aborts the signature or only downgrades the level (see
PAdES Baseline Levels).
package com.example.billing;
import com.itextpdf.io.resolver.resource.DefaultResourceRetriever;
import com.itextpdf.signatures.OcspClientBouncyCastle;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.security.MessageDigest;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** A "responder" that accepts the request and then stalls proves that the calls give up. */
class NetworkTimeoutTest {
private HttpServer stalled;
@BeforeEach
void startStalledResponder() throws IOException {
stalled = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
stalled.createContext("/", exchange -> {
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
exchange.close();
});
stalled.start();
}
@AfterEach
void stop() {
stalled.stop(0);
}
private String url() {
return "http://127.0.0.1:" + stalled.getAddress().getPort() + "/";
}
@Test
void tsaClientGivesUp() throws Exception {
TimeoutTsaClient tsa = new TimeoutTsaClient(url(), Duration.ofMillis(500), Duration.ofSeconds(1));
byte[] imprint = MessageDigest.getInstance("SHA-256").digest(new byte[] {1, 2, 3});
long start = System.nanoTime();
assertThrows(Exception.class, () -> tsa.getTimeStampToken(imprint));
assertTrue(Duration.ofNanos(System.nanoTime() - start).toSeconds() < 5);
}
@Test
void ocspClientGivesUp() throws Exception {
DefaultResourceRetriever retriever = new DefaultResourceRetriever();
retriever.setConnectTimeout(500);
retriever.setReadTimeout(1_000);
retriever.setResourceSizeByteLimit(1_000_000);
OcspClientBouncyCastle ocsp = new OcspClientBouncyCastle().withResourceRetriever(retriever);
TestPki pki = TestPki.selfSigned("Bookshelf Billing Test Signer");
long start = System.nanoTime();
byte[] answer = ocsp.getEncoded(pki.certificate(), pki.certificate(), url());
assertNull(answer);
assertTrue(Duration.ofNanos(System.nanoTime() - start).toSeconds() < 5);
}
}
Choose values from the TSA’s published service level rather than guessing: a connect timeout of a second or two and a
total of five to ten seconds is typical, and a retry belongs in the caller, with a limit, not in the client. Also decide
what the product does when the TSA is down: queue the document for later timestamping, or fail the request. For
automated tests and demonstrations use a public test TSA such as freetsa.org and never put its address in production
configuration.
Official documentation: iText Javadoc for TSAClientBouncyCastle, OcspClientBouncyCastle, CrlClientOnline and DefaultResourceRetriever, and the Java SE HttpClient.
Secrets for signing keys
A signing key is the most valuable secret of a PDF service: whoever holds it can issue documents in the company’s name. The rules are the usual ones, with two that are specific to signing.
-
The key is not in the repository, in the image, or in an
application.propertiesfile. It reaches the process as a mounted file or from a secret store at start-up. -
The password is not an environment variable if you can avoid it: environment variables leak into process listings, crash dumps and
docker inspect. Mount it as a file (Docker and Kubernetes secrets are mounted under a path such as/run/secrets) and read it into achar[]that is cleared after use. -
The best key is the one that never leaves a hardware module. A key in an HSM or a key management service signs the document hash remotely, so the service holds no key to leak; that route is described in Remote HSM & Deferred Signing. Where a file key is unavoidable, the class below loads a PKCS#12 keystore whose path and password file come from the environment.
-
Rotate: certificates expire, and a signing service should log the expiry date of the certificate it loaded and expose the number of days left as a gauge, so the renewal is an alert and not an outage.
package com.example.billing;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.util.Arrays;
/** Loads the signing key from a mounted secret: the keystore path and the password file come from the environment. */
public record SigningKeystore(PrivateKey privateKey, Certificate[] chain) {
public static SigningKeystore fromEnvironment(String alias) throws IOException, GeneralSecurityException {
return load(requiredPath("SIGNING_KEYSTORE_FILE"), requiredPath("SIGNING_KEYSTORE_PASSWORD_FILE"), alias);
}
static SigningKeystore load(Path keystore, Path passwordFile, String alias)
throws IOException, GeneralSecurityException {
byte[] raw = Files.readAllBytes(passwordFile);
char[] password = new String(raw, StandardCharsets.UTF_8).strip().toCharArray();
Arrays.fill(raw, (byte) 0);
try (InputStream in = Files.newInputStream(keystore)) {
KeyStore store = KeyStore.getInstance("PKCS12");
store.load(in, password);
PrivateKey key = (PrivateKey) store.getKey(alias, password);
Certificate[] chain = store.getCertificateChain(alias);
if (key == null || chain == null) {
throw new IllegalStateException("No private key and chain under alias " + alias);
}
return new SigningKeystore(key, chain);
} finally {
Arrays.fill(password, '\0');
}
}
private static Path requiredPath(String variable) {
String value = System.getenv(variable);
if (value == null || value.isBlank()) {
throw new IllegalStateException("Environment variable " + variable + " is not set");
}
return Path.of(value);
}
}
The test generates a random password, writes it to a temporary file together with a keystore, and loads both; no secret appears in the source, and nothing outlives the test.
package com.example.billing;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.SecureRandom;
import java.security.cert.Certificate;
import java.util.HexFormat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
class SigningKeystoreTest {
@Test
void readsKeystoreAndPasswordFromMountedFiles(@TempDir Path dir) throws Exception {
// A random password per run: nothing secret is written in the test source
byte[] random = new byte[16];
new SecureRandom().nextBytes(random);
String password = HexFormat.of().formatHex(random);
Path passwordFile = Files.writeString(dir.resolve("keystore-password"), password + "\n");
TestPki pki = TestPki.selfSigned("Bookshelf Billing Test Signer");
KeyStore store = KeyStore.getInstance("PKCS12");
store.load(null, null);
store.setKeyEntry("billing-signer", pki.keys().getPrivate(), password.toCharArray(),
new Certificate[] {pki.certificate()});
Path keystore = dir.resolve("billing-signer.p12");
try (OutputStream out = Files.newOutputStream(keystore)) {
store.store(out, password.toCharArray());
}
SigningKeystore loaded = SigningKeystore.load(keystore, passwordFile, "billing-signer");
assertNotNull(loaded.privateKey());
assertEquals(1, loaded.chain().length);
}
}
Cloud secret stores and key services are described on the site: AWS Secrets Manager and KMS, Azure Key Vault and Secrets and Google Cloud Secret Manager and KMS.
Official documentation: Docker secrets in Compose and Swarm, and the Kubernetes Secrets concept page.
Licence compliance
The licence of a PDF library is a production concern because it can force a decision about the whole service. The introduction of this section, Introduction & Approaches, explains the consequences in detail; the table is the operational summary. It is engineering guidance, not legal advice. The licences were read from the Maven POMs and project pages on 2026-10-09.
| Component | Licence | What to check |
|---|---|---|
iText Core 9.8.0 and the open add-ons (pdfHTML, pdfSweep, pdfOCR) |
AGPLv3 or commercial |
A service that users reach over a network must offer its source under the AGPL, unless a commercial licence is bought. Commercial-only add-ons come from the vendor’s own repository. |
JasperReports 7.0.8 |
LGPL |
Unmodified use as a dependency does not open your source; changes to the library do. Tagged PDF needs the commercial edition. |
OpenPDF 3.0.5 (and the JasperReports fork) |
LGPL and MPL |
Same reasoning as the LGPL above; track which of the two licences you rely on. |
Apache PDFBox 3.0.8, Micrometer |
Apache-2.0 |
Keep the notice files in the distribution. |
veraPDF validation library |
GPLv3+ or MPLv2+ |
Use it as a test dependency only, as above, so it does not ship. |
Two habits make this checkable. First, look at the dependency tree in CI (mvn dependency:tree) and review any new
artifact in a pull request. Second, make the licence of the PDF library visible in a test. iText writes its edition into
the Producer entry of the document information: the run on this page printed iText® Core 9.8.0 (AGPL version)
©2000-2026 Apryse Group NV. The test below fails the build if the producer line stops saying AGPL, which typically happens when
a commercial licence file is loaded, or when someone replaces the library. Flip the assertion once you hold a commercial
licence, so it then guards the opposite mistake: a build that quietly fell back to the AGPL edition.
package com.example.billing;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfReader;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Makes the licence of the PDF library visible: the build fails if the producer line changes unexpectedly. */
class ProducerLicenceTest {
@Test
void producerLineNamesTheAgplEdition() throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
InvoicePdf.write("BB-2026-0001", "Ada Lovelace", 1, out);
try (PdfDocument pdf = new PdfDocument(new PdfReader(new ByteArrayInputStream(out.toByteArray())))) {
String producer = pdf.getDocumentInfo().getProducer();
System.out.println("Producer: " + producer);
// With a commercial licence file loaded the line has no "AGPL" marker. Decide which one this build is.
assertTrue(producer.contains("AGPL"), "Unexpected producer line: " + producer);
}
}
}
Official documentation: the GNU AGPL v3 text, the JasperReports licence page, the OpenPDF project and the Apache License 2.0.
A pre-release checklist
-
Text assertions cover every number, date and name that a customer would complain about.
-
A golden file or a structure test covers the layout, and its failure output is uploaded by CI.
-
PDF/A and PDF/UA documents pass veraPDF in CI, and a deliberately broken file proves that the check can fail.
-
Signed documents pass an integrity check in the test suite and a full validator in a slower job.
-
The number of concurrent renderings is bounded, and a rejected request is counted.
-
Every call to a TSA, OCSP responder or CRL server has a connect timeout and a deadline, and the product has a decision for each of them being down.
-
The signing key and its password are mounted, not baked in, and the days to expiry are a metric.
-
Fonts are embedded or installed in the image, and the JVM runs headless.
-
The licence of every PDF component is known, and a test makes the iText edition visible.