Editable Forms with JasperReports & PDFBox

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.

An editable PDF form is a normal page description with an extra layer on top: a tree of fields (the data model) and one or more widget annotations per field (the rectangles a viewer draws and a user clicks). That layer is called an AcroForm, and it is the only form technology to use today (see the section on outdated points below). This page covers the two tools that suit a Java service that already generates reports with JasperReports: the JasperReports PDF exporter, which can turn selected report elements into fields (since 6.12.0), and Apache PDFBox 3.0.8, which can open the exported file, fill it, flatten it, add the field types the exporter cannot produce and, when needed, build a form from nothing. The running example is the editable customer onboarding form of the Bookshelf Billing scenario: the back office renders a form with the customer number already printed, the customer types a name and an address, chooses a country and a plan, and the file is later flattened or signed. Versions are stated as of 2026-10-09: JasperReports 7.0.8 (with jasperreports-pdf and jasperreports-jdt) and Apache PDFBox 3.0.8 on Java 21. Every Java example and the JRXML template on this page were compiled and run against exactly those versions, and the resulting PDFs were inspected with PDFBox and the Python library pypdf and rendered to images with PDFBox. What was not tested is listed in the section What was run, and what was not.

The concepts of an AcroForm (field types, flags, the value and appearance model, XFA, flattening and form-signing order) are explained in Editable Forms Fundamentals; the same task with iText instead of PDFBox is in Editable Forms with iText. How the exporter itself is configured is in PDF Export Configuration, the v7 template syntax is in JRXML Report Design, and the Spring Boot plumbing that returns bytes to a client is in Getting Started with Spring Boot. Signing a form after it has been filled is covered in Signing with PDFBox and EU DSS and Signing with iText.

Who does what

Two different moments are involved, and the tools split along that line.

flowchart LR T[JRXML template with field properties] --> F[JasperReports fill] F --> E[JRPdfExporter writes AcroForm fields] E --> P[Editable PDF] P --> U[Customer types in a viewer] P --> S[Back office fills with PDFBox] S --> L[Flatten to plain content] S --> G[Add signature field and sign] U --> R[Returned PDF read back with PDFBox]
  • Design time, in the report template. JasperReports decides which elements become fields, what their names are and what choices a list offers. It does this through element properties on the JRXML element, read by the PDF exporter. The exporter writes the field, the widget and the appearance in one pass.

  • Run time, on the exported bytes. PDFBox opens the file with Loader.loadPDF, reads and writes values, flattens the form into page content, and adds what the exporter cannot create. JasperReports itself has no API to fill a field after export, and the properties that set an initial value are the only way to pre-fill from inside a report.

PDFBox is Apache-2.0 licensed, so using it in a closed-source service needs no licence purchase. JasperReports Community is LGPL-3.0 licensed. Both are used here in their free editions; the form export described below is part of the Community engine (tagged PDF, in contrast, needs the Professional edition in 7.0.8, see PDF Export Configuration).

What changed since the books

The consulted books on JasperReports and iText predate form export in JasperReports, and some of them do not mention PDF/A, tagging, form fields or signing at all (finding O27 of this section’s source analysis). Four points follow from that and are stated here once.

  • JasperReports can write AcroForm fields, since version 6.12.0. Before it, an element that looked like a text box was only a drawing, and a form meant a second tool. The properties are documented in the configuration reference with Since: 6.12.0, and the sample that demonstrates them is the official forms sample.

  • JasperReports cannot sign, and it cannot read or fill a form it has produced. Signing, like filling after export, is a post-processing step on the bytes.

  • PDFBox 3 is not PDFBox 1 or 2. PDDocument.load(...) is gone and Loader.loadPDF(...) replaces it; the PDType1Font constants such as PDType1Font.HELVETICA are replaced by new PDType1Font(Standard14Fonts.FontName.HELVETICA); and PDAcroForm.flatten() and its overloads are the supported flattening API. Old snippets that call setNeedAppearances as the only way to show a value are no longer the recommended approach: setValue on a field regenerates its appearance. The migration guide at https://pdfbox.apache.org/3.0/migration.html lists the removed methods.

  • XFA is not the answer. XFA forms are deprecated in PDF 2.0 and forbidden by PDF/A. Both tools on this page work with AcroForms only.

Dependencies

The PDF exporter is not in the core jasperreports artifact, and a template compiler has to be on the class path (Eclipse JDT in this example, as in the other JasperReports pages). PDFBox 3 needs one artifact.

<dependencies>
  <dependency>
    <groupId>net.sf.jasperreports</groupId>
    <artifactId>jasperreports</artifactId>
    <version>7.0.8</version>
  </dependency>
  <dependency>
    <groupId>net.sf.jasperreports</groupId>
    <artifactId>jasperreports-pdf</artifactId>
    <version>7.0.8</version>
  </dependency>
  <dependency>
    <groupId>net.sf.jasperreports</groupId>
    <artifactId>jasperreports-jdt</artifactId>
    <version>7.0.8</version>
  </dependency>
  <dependency>
    <groupId>org.apache.pdfbox</groupId>
    <artifactId>pdfbox</artifactId>
    <version>3.0.8</version>
  </dependency>
</dependencies>

pdfbox brings pdfbox-io and fontbox transitively. On the first run PDFBox scans the operating system fonts and logs a message while it builds its font cache, which is harmless. Derived from the PDFBox dependencies page and the other JasperReports pages of this section.

Declaring fields in the template

A field is declared by properties on a report element in the JRXML. The names start with net.sf.jasperreports.export.pdf.field. and every one of them has scope Element (a few can also be set globally, see below). The element is what the user sees in a non-form export, so the properties only add behaviour: size, position, background, font and text colour come from the element, and the exporter reuses them for the field.

Property Meaning Applies to

net.sf.jasperreports.export.pdf.field.type

The kind of field: Text, Combo, List, Check or Radio. Without it the element is not a field. Text can be used only on text elements (static text and text fields).

every field

net.sf.jasperreports.export.pdf.field.name

The field name, unique within the form. If missing, the name is generated from the element UUID. Radio options that share a name become one radio group.

every field

net.sf.jasperreports.export.pdf.field.value

The initial text of a text field, or the initially selected choice of a list or combo. If missing, the text content of the element is used.

Text, Combo, List

net.sf.jasperreports.export.pdf.field.choices

The choices of a list or combo, separated by | unless another separator is configured.

Combo, List

net.sf.jasperreports.export.pdf.field.choice.separators

The characters that separate the choices. Default |; the official sample uses a comma.

Combo, List

net.sf.jasperreports.export.pdf.field.combo.edit

A flag: when true the combo box is editable, so the user can type a value that is not in the list. Default false.

Combo

net.sf.jasperreports.export.pdf.field.check.type

The glyph of a check box or radio option: Check, Circle, Cross, Diamond, Square or Star.

Check, Radio

net.sf.jasperreports.export.pdf.field.checked

A flag: the box or the option is selected when the file opens.

Check, Radio

net.sf.jasperreports.export.pdf.field.border.style

Solid, Dashed, Beveled, Inset or Underline. If missing, the pen style of the element is used (Dashed when dashed, Solid otherwise). The thickness is rounded to 1, 2 or 3.

every field

net.sf.jasperreports.export.pdf.field.read.only

A flag: the field cannot be edited. The reference states that this is not applicable to radio groups.

every field except Radio

net.sf.jasperreports.export.pdf.field.text.multiline

A flag: a text field accepts several lines. Default false.

Text

Derived from the JasperReports configuration reference and the forms sample. The configuration reference states that the type, choices, separators and combo properties exist in 6.12.0 or later and documents the Java constants for them on net.sf.jasperreports.pdf.JRPdfExporter (PDF_FIELD_TYPE, PDF_FIELD_NAME and so on). The sample README states "Since: 6.12.0" for the whole feature.

The template

The template below is a complete v7 JRXML document for the onboarding form. It uses static texts as the empty text fields (the Text type reads the element content, which is empty here), a text field for the customer number, which is printed from a report parameter and made read-only, a combo box, a list, a check box and a radio group of two options. Inside an expression or an attribute, a $P{...} reference is the JasperReports parameter syntax; the lines that declare the fields are the property elements.

<jasperReport name="onboarding-form" language="java" pageWidth="595" pageHeight="842"
              columnWidth="515" leftMargin="40" rightMargin="40" topMargin="40" bottomMargin="40">
  <parameter name="CUSTOMER_ID" class="java.lang.String"/>
  <title height="440">
    <element kind="staticText" x="0" y="0" width="515" height="30" fontSize="18" bold="true">
      <text><![CDATA[Bookshelf customer onboarding]]></text>
    </element>

    <element kind="staticText" x="0" y="45" width="140" height="22">
      <text><![CDATA[Customer number]]></text>
    </element>
    <element kind="textField" x="150" y="45" width="200" height="22" backcolor="#EEEEEE" mode="Opaque">
      <expression><![CDATA[$P{CUSTOMER_ID}]]></expression>
      <property name="net.sf.jasperreports.export.pdf.field.type" value="Text"/>
      <property name="net.sf.jasperreports.export.pdf.field.name" value="customerId"/>
      <property name="net.sf.jasperreports.export.pdf.field.read.only" value="true"/>
    </element>

    <element kind="staticText" x="0" y="80" width="140" height="22">
      <text><![CDATA[Full name]]></text>
    </element>
    <element kind="staticText" x="150" y="80" width="365" height="22">
      <text><![CDATA[]]></text>
      <property name="net.sf.jasperreports.export.pdf.field.type" value="Text"/>
      <property name="net.sf.jasperreports.export.pdf.field.name" value="fullName"/>
      <box>
        <pen lineWidth="1.0" lineColor="#444444"/>
      </box>
    </element>

    <element kind="staticText" x="0" y="115" width="140" height="22">
      <text><![CDATA[Billing address]]></text>
    </element>
    <element kind="staticText" x="150" y="115" width="365" height="60">
      <text><![CDATA[]]></text>
      <property name="net.sf.jasperreports.export.pdf.field.type" value="Text"/>
      <property name="net.sf.jasperreports.export.pdf.field.name" value="billingAddress"/>
      <property name="net.sf.jasperreports.export.pdf.field.text.multiline" value="true"/>
      <property name="net.sf.jasperreports.export.pdf.field.border.style" value="Beveled"/>
      <box>
        <pen lineWidth="1.0" lineColor="#444444"/>
      </box>
    </element>

    <element kind="staticText" x="0" y="190" width="140" height="22">
      <text><![CDATA[Country]]></text>
    </element>
    <element kind="staticText" x="150" y="190" width="200" height="22">
      <text><![CDATA[]]></text>
      <property name="net.sf.jasperreports.export.pdf.field.type" value="Combo"/>
      <property name="net.sf.jasperreports.export.pdf.field.name" value="country"/>
      <property name="net.sf.jasperreports.export.pdf.field.choices" value="Spain|France|Portugal|Other"/>
      <property name="net.sf.jasperreports.export.pdf.field.value" value="Spain"/>
      <box>
        <pen lineWidth="1.0" lineColor="#444444"/>
      </box>
    </element>

    <element kind="staticText" x="0" y="225" width="140" height="22">
      <text><![CDATA[Interests]]></text>
    </element>
    <element kind="staticText" x="150" y="225" width="200" height="70">
      <text><![CDATA[]]></text>
      <property name="net.sf.jasperreports.export.pdf.field.type" value="List"/>
      <property name="net.sf.jasperreports.export.pdf.field.name" value="interests"/>
      <property name="net.sf.jasperreports.export.pdf.field.choice.separators" value=","/>
      <property name="net.sf.jasperreports.export.pdf.field.choices" value="Fiction,Poetry,Science,History"/>
      <box>
        <pen lineWidth="1.0" lineColor="#444444"/>
      </box>
    </element>

    <element kind="staticText" x="0" y="310" width="140" height="22">
      <text><![CDATA[Newsletter]]></text>
    </element>
    <element kind="rectangle" x="150" y="310" width="20" height="20">
      <property name="net.sf.jasperreports.export.pdf.field.type" value="Check"/>
      <property name="net.sf.jasperreports.export.pdf.field.name" value="newsletter"/>
      <property name="net.sf.jasperreports.export.pdf.field.check.type" value="Cross"/>
      <property name="net.sf.jasperreports.export.pdf.field.checked" value="true"/>
    </element>

    <element kind="staticText" x="0" y="345" width="140" height="22">
      <text><![CDATA[Plan]]></text>
    </element>
    <element kind="rectangle" x="150" y="345" width="20" height="20">
      <property name="net.sf.jasperreports.export.pdf.field.type" value="Radio"/>
      <property name="net.sf.jasperreports.export.pdf.field.name" value="plan"/>
      <property name="net.sf.jasperreports.export.pdf.field.check.type" value="Circle"/>
      <property name="net.sf.jasperreports.export.pdf.field.checked" value="true"/>
    </element>
    <element kind="staticText" x="175" y="345" width="60" height="20">
      <text><![CDATA[Basic]]></text>
    </element>
    <element kind="rectangle" x="250" y="345" width="20" height="20">
      <property name="net.sf.jasperreports.export.pdf.field.type" value="Radio"/>
      <property name="net.sf.jasperreports.export.pdf.field.name" value="plan"/>
      <property name="net.sf.jasperreports.export.pdf.field.check.type" value="Circle"/>
    </element>
    <element kind="staticText" x="275" y="345" width="60" height="20">
      <text><![CDATA[Premium]]></text>
    </element>
  </title>
</jasperReport>

A few details of the template are worth stating.

  • A check box or radio option needs an element with a shape. A rectangle works and an ellipse is what the official sample uses. The exporter draws the glyph named by check.type, so the element itself only provides the position and size. The sample also puts a radio option on a text element, which is accepted too.

  • A radio group is made of several elements that share the same field.name. Each element is one option, and field.checked selects the one that is selected when the file opens. Without the shared name the options would be separate groups.

  • A list or combo ignores the element text. The element can contain anything, because the displayed text comes from the choices and the value.

  • A border comes from the <box> pen of the element. Without a pen the field has no visible border, which is why the read-only customer number uses a grey background instead.

  • A multi-line text field in the official sample also sets net.sf.jasperreports.print.keep.full.text to true on elements whose content is long, so that the full text and not only the part that fits the element is exported. It is unnecessary for an empty field.

The properties are set per element here. The reference lists the choice separators and the combo edit flag with a wider scope (global, context, report and element), so a default for them can be configured in a jasperreports.properties file, but the name, type, value, choices, checked and read-only properties are element-level only.

Exporting the form

Exporting is the same code as any PDF export, as described in PDF Export Configuration: there is no form-specific option on the exporter, and the fields appear because the properties are present. Two details are easy to miss: the parameter map must be mutable because the fill manager adds its own entries (an immutable Map.of(...) failed with an UnsupportedOperationException in the run used for this page), and a new JRPdfExporter is created for each call.

package bookshelf;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;

import net.sf.jasperreports.engine.JREmptyDataSource;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JasperCompileManager;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperPrint;
import net.sf.jasperreports.engine.JasperReport;
import net.sf.jasperreports.export.SimpleExporterInput;
import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput;
import net.sf.jasperreports.pdf.JRPdfExporter;

/** Fills the onboarding template and exports it to a PDF that carries AcroForm fields. */
public final class OnboardingFormExport {

    private OnboardingFormExport() {
    }

    public static void export(Path target, String customerId) throws JRException, IOException {
        try (InputStream template = OnboardingFormExport.class
                .getResourceAsStream("/reports/onboarding-form.jrxml")) {
            JasperReport report = JasperCompileManager.compileReport(template);
            // The fill manager adds its own entries to the map, so it must be mutable: Map.of(...) fails.
            Map<String, Object> parameters = new HashMap<>();
            parameters.put("CUSTOMER_ID", customerId);
            JasperPrint print = JasperFillManager.fillReport(report, parameters, new JREmptyDataSource(1));

            JRPdfExporter exporter = new JRPdfExporter();
            exporter.setExporterInput(new SimpleExporterInput(print));
            try (var out = Files.newOutputStream(target)) {
                exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(out));
                exporter.exportReport();
            }
        }
    }

    public static void main(String[] args) throws Exception {
        export(Path.of(args.length > 0 ? args[0] : "onboarding-form.pdf"), "BK-000123");
    }
}

Derived from the forms sample and the JRPdfExporter API.

Reading the result with PDFBox

Before filling anything, look at what the exporter wrote. PDAcroForm.getFieldTree() walks every field, and a field is either a non-terminal field (a group, with children and no widget) or a terminal field, which is the one that holds a value and one or more widgets.

package bookshelf;

import java.io.IOException;
import java.nio.file.Path;

import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;

/** Prints every field of a PDF form: the fully qualified name, the class, the value and the widget count. */
public final class FormInspector {

    private FormInspector() {
    }

    public static void print(Path pdf) throws IOException {
        try (PDDocument document = Loader.loadPDF(pdf.toFile())) {
            PDAcroForm form = document.getDocumentCatalog().getAcroForm();
            if (form == null) {
                System.out.println("no AcroForm");
                return;
            }
            System.out.println("NeedAppearances=" + form.getNeedAppearances()
                    + " XFA=" + form.hasXFA());
            for (PDField field : form.getFieldTree()) {
                if (field instanceof PDNonTerminalField) {
                    System.out.println("  group   " + field.getFullyQualifiedName());
                } else {
                    PDTerminalField terminal = (PDTerminalField) field;
                    System.out.printf("  %-8s %-22s value=%-14s widgets=%d%n",
                            field.getFieldType(), field.getFullyQualifiedName(),
                            field.getValueAsString(), terminal.getWidgets().size());
                }
            }
        }
    }

    public static void main(String[] args) throws IOException {
        print(Path.of(args[0]));
    }
}

Run on the exported file, with the radio group UUIDs shortened here, the output was:

NeedAppearances=false XFA=false
  Tx       customerId             value=BK-000123      widgets=1
  Tx       fullName               value=               widgets=1
  Tx       billingAddress         value=               widgets=1
  Ch       country                value=[Spain]        widgets=1
  Ch       interests              value=[Fiction]      widgets=1
  Btn      newsletter             value=checked        widgets=1
  Btn      plan                   value=FIELD_<uuid>   widgets=2

What this shows, and what is a result of this run rather than of the official documentation:

  • The types map directly: Tx for text, Ch for the combo and the list, Btn for the check box and the radio group. PDField.getFieldType() returns these PDF names, and the Java class is PDTextField, PDComboBox, PDListBox, PDCheckBox or PDRadioButton, as the next section uses.

  • The radio options became one field with two widgets, as intended. Their on-states are named FIELD_ followed by the UUID of each element, and they are different on every export. Code that fills the group must read them from the file (the example below does) and must not hard-code a value.

  • The check box on-state is named checked, and the exporter chose Spain as the combo value from field.value. When no field.value is given for a list, the first choice is selected in this run (Fiction above).

  • The customerId field has the read-only flag (bit 1, /Ff 1) and billingAddress has the multiline flag (/Ff 4096); country has the combo flag (131072) and the radio group has the radio flag (32768) with the no-toggle-to-off flag (16384).

  • The widgets have no /P entry (the reference from the widget to its page). PDFBox logs a warning for each widget when it builds its page map during flattening, and it still finds the widget by scanning the page annotations. The warning is harmless but noisy; the next sections show where it appears.

  • NeedAppearances is false: the exporter writes an appearance stream for every widget, so a viewer does not have to regenerate them.

Derived from the PDAcroForm Javadoc.

Filling a form

Filling is form.getField(name), a cast to the field class, and setValue. PDFBox regenerates the appearance of the field when a value is set, so the result shows in viewers that do not regenerate it themselves.

package bookshelf;

import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;

/** Fills the form exported by JasperReports and writes a filled copy and a flattened copy. */
public final class FillAndFlatten {

    private FillAndFlatten() {
    }

    public static void fill(Path source, Path filled) throws IOException {
        try (PDDocument document = Loader.loadPDF(source.toFile())) {
            PDAcroForm form = document.getDocumentCatalog().getAcroForm();

            ((PDTextField) form.getField("fullName")).setValue("Ada Example");
            ((PDTextField) form.getField("billingAddress")).setValue("1 Fictitious Street\nSampletown");
            ((PDComboBox) form.getField("country")).setValue("Portugal");
            // The list that JasperReports writes is single-select: a second value is rejected.
            ((PDListBox) form.getField("interests")).setValue("Science");

            PDCheckBox newsletter = (PDCheckBox) form.getField("newsletter");
            newsletter.unCheck();

            // JasperReports names each radio option after its element (FIELD_ and a UUID that changes on every
            // export), so read the "on" state of the second widget instead of hard-coding a value.
            PDRadioButton plan = (PDRadioButton) form.getField("plan");
            plan.setValue(onState(plan.getWidgets().get(1)));

            document.save(filled.toFile());
        }
    }

    private static String onState(PDAnnotationWidget widget) {
        return widget.getAppearance().getNormalAppearance().getSubDictionary().keySet().stream()
                .map(COSName::getName)
                .filter(name -> !"Off".equals(name))
                .findFirst()
                .orElseThrow();
    }

    public static void flatten(Path filled, Path flat) throws IOException {
        try (PDDocument document = Loader.loadPDF(filled.toFile())) {
            PDAcroForm form = document.getDocumentCatalog().getAcroForm();
            // Flatten every field of the tree (the same as flatten()), refreshing each appearance first.
            List<PDField> toFlatten = new ArrayList<>();
            form.getFieldTree().forEach(toFlatten::add);
            form.flatten(toFlatten, true);
            document.save(flat.toFile());
        }
    }

    public static void main(String[] args) throws IOException {
        fill(Path.of(args[0]), Path.of(args[1]));
        flatten(Path.of(args[1]), Path.of(args[2]));
    }
}

The behaviour of each field type, as observed in the run, is as follows.

  • Text and multi-line text: setValue(String). A line break in the value is kept (the address above shows on two lines). The text is clipped to the widget rectangle, and a font size of 0 in the default appearance means automatic size.

  • Combo: setValue(String) with one of the options. A value that is not one of the options is meant for an editable combo (the combo.edit property); that case was not exercised.

  • List: the list written by JasperReports is single select, and PDFBox rejects more than one value with an IllegalArgumentException ("The list box does not allow multiple selections."). The properties above have no multi-select flag, so a multi-select list needs the field flags changed with PDFBox (PDChoice.setMultiSelect(true)) before values are set; that was not exercised.

  • Check box: check() and unCheck(), or setValue with the on-state name. In the run the check box was cleared with unCheck() and read back as Off.

  • Radio group: setValue with the on-state of the chosen option. Read it from the widget (the helper reads the keys of the normal appearance dictionary and drops Off) because the JasperReports names are random.

  • Read-only fields can still be changed by code. The read-only flag only affects what the user can do in a viewer, and PDFBox does not enforce it, so a server that fills customerId would not be stopped. Do not treat a read-only flag as a security control (see Encryption and Permissions for what is and is not enforced).

Derived from the PDFBox example FillFormField.java and the PDAcroForm Javadoc.

Rendering and pre-filling in one service

A Spring Boot controller usually wants bytes in and bytes out. The same two tools can be chained entirely in memory: export into a ByteArrayOutputStream, load the bytes with Loader.loadPDF(byte[]), set the values and save into another ByteArrayOutputStream. The compiled report is immutable and can be a singleton bean; the exporter and the PDDocument are per call. A controller then returns the bytes with the content type application/pdf (see Getting Started with Spring Boot and JasperReports Spring Boot Integration).

package bookshelf;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;

import net.sf.jasperreports.engine.JREmptyDataSource;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JasperCompileManager;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperReport;
import net.sf.jasperreports.export.SimpleExporterInput;
import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput;
import net.sf.jasperreports.pdf.JRPdfExporter;

import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;

/** Renders the form in memory and pre-fills it, so a controller can return the bytes directly. */
public final class PrefilledFormService {

    private final JasperReport report;   // compile once, reuse: the compiled report is immutable

    public PrefilledFormService() throws JRException, IOException {
        try (InputStream template = getClass().getResourceAsStream("/reports/onboarding-form.jrxml")) {
            this.report = JasperCompileManager.compileReport(template);
        }
    }

    public byte[] render(String customerId, String fullName) throws JRException, IOException {
        Map<String, Object> parameters = new HashMap<>();
        parameters.put("CUSTOMER_ID", customerId);
        ByteArrayOutputStream exported = new ByteArrayOutputStream();
        JRPdfExporter exporter = new JRPdfExporter();   // a new exporter per call
        exporter.setExporterInput(new SimpleExporterInput(
                JasperFillManager.fillReport(report, parameters, new JREmptyDataSource(1))));
        exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(exported));
        exporter.exportReport();

        try (PDDocument document = Loader.loadPDF(exported.toByteArray())) {
            PDAcroForm form = document.getDocumentCatalog().getAcroForm();
            ((PDTextField) form.getField("fullName")).setValue(fullName);
            ByteArrayOutputStream result = new ByteArrayOutputStream();
            document.save(result);
            return result.toByteArray();
        }
    }
}

The service was run once and the returned bytes were inspected with the inspector above; no controller was run. The Loader.loadPDF overloads without a stream cache argument already use IOUtils.createMemoryOnlyStreamCache(), so the scratch data of the document stays on the heap. For large files pass IOUtils.createTempFileOnlyStreamCache(), or MemoryUsageSetting.setupMixed(maxMainMemoryBytes).streamCache to keep a bounded amount in memory and spill the rest to a temporary file, to the Loader.loadPDF overload that takes a stream cache function (all of them exist in pdfbox-io 3.0.8); that overload was not exercised here.

Flattening

Flattening turns the fields into ordinary page content and removes the form, so a recipient can no longer edit anything and a text extractor sees the values as text. It is the right last step for an invoice or an archived form. In the example, flatten(fields, true) takes the list of fields to flatten and a flag that says to refresh their appearance first. The no-argument flatten() flattens every field, and passing a subset leaves the others editable. After the full flatten of the example the field tree is empty: the inspector printed only its header line for the flattened file.

Observed after flattening the filled file: the text extracted by pypdf contained the typed name, the address on two lines, the country and the four list options, and the rendered page showed the selected list entry highlighted, the chosen radio option marked and the cleared check box empty. Two consequences are worth knowing.

  • A flattened list box keeps the whole list visible, with the selected line highlighted. It does not collapse to the selected value as a combo does.

  • Flatten after the customer has finished, and before a signature that must cover the final content. A signed file that is later flattened loses the signature, and a form that is meant to stay editable after signing needs the incremental-save approach of the signing pages. The order fill, flatten, sign is the one used in Signing with iText, and the same applies when the signer is PDFBox or EU DSS.

The PDFBox warnings about missing /P entries described above appear while flattening a JasperReports file. They can be silenced through the logging configuration of org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm.

Derived from the PDAcroForm Javadoc and the PDFBox example FillFormField.java.

What the exporter cannot produce

The JasperReports property list above is closed: the documented field properties cover text, combo, list, check box and radio fields, and nothing else. In particular there is no property for push buttons, for signature fields, for required fields, for a maximum length, for calculated fields or for JavaScript actions. A report that needs one of them has to add it after the export, with PDFBox. The most common case on the onboarding form is the signature field, an empty field the customer signs later.

package bookshelf;

import java.io.IOException;
import java.nio.file.Path;

import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;

/** Adds the one thing the JasperReports exporter cannot produce: an empty signature field. */
public final class AddSignatureField {

    private AddSignatureField() {
    }

    public static void add(Path source, Path target) throws IOException {
        try (PDDocument document = Loader.loadPDF(source.toFile())) {
            PDAcroForm form = document.getDocumentCatalog().getAcroForm();
            PDPage page = document.getPage(0);

            PDSignatureField signature = new PDSignatureField(form);
            signature.setPartialName("customerSignature");
            PDAnnotationWidget widget = signature.getWidgets().get(0);
            widget.setRectangle(new PDRectangle(150, 250, 200, 60));
            widget.setPage(page);
            widget.setPrinted(true);
            page.getAnnotations().add(widget);
            form.getFields().add(signature);

            // SignaturesExist (1) | AppendOnly (2): later signatures must be incremental updates.
            form.setSignaturesExist(true);
            form.setAppendOnly(true);
            document.save(target.toFile());
        }
    }

    public static void main(String[] args) throws IOException {
        add(Path.of(args[0]), Path.of(args[1]));
    }
}

setSignaturesExist(true) and setAppendOnly(true) set the two /SigFlags bits that tell a viewer that signatures exist and that changes must be made as incremental updates. The field has no appearance, so it is invisible until a signature is applied or an appearance is drawn; a visible placeholder needs an appearance stream like the one in the next section. The signing itself (PDFBox SignatureInterface, EU DSS) is in Signing with PDFBox and EU DSS. The run confirmed that the field is present in the form and reported as Sig; no signature was applied on this page.

Because the JasperReports properties write one widget per element, a field that appears in several places (a customer number repeated on every page, for example) cannot be declared in the template with the same name and be a single field with several widgets. The recommended way is to create the field once with PDFBox and add the widgets, as shown next.

Creating a form with PDFBox

PDFBox can also build a form from nothing, which is useful when there is no report template or when the fields must be added to a PDF made by another tool. The model is the same as in the inspector: a PDAcroForm attached to the catalog, a list of fields, and one PDAnnotationWidget per visual location, added to the page annotations. Four rules decide whether the result works.

  1. Set default resources and a default appearance on the form. The default appearance string such as /Helv 0 Tf 0 g names a font that must exist in the form’s /DR resources, otherwise setting a value fails.

  2. A widget must be linked in both directions: widget.setPage(page) and page.getAnnotations().add(widget), and for a field with several widgets widget.setParent(field) as well.

  3. A check box or radio option needs appearance streams for its states: an "on" state with a name and an Off state. PDFBox does not draw them for you, so the example draws a cross and a filled square.

  4. A radio widget is invisible until its /AS entry names a state. PDFBox sets the selected option’s state when the value is set; the unselected widget needs Off set explicitly, which the example does.

package bookshelf;

import java.io.IOException;
import java.nio.file.Path;
import java.util.List;

import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;

/** Builds a two-page form from nothing: text field, check box, radio group and a field with two widgets. */
public final class CreateForm {

    private CreateForm() {
    }

    public static void create(Path target) throws IOException {
        try (PDDocument document = new PDDocument()) {
            PDPage page1 = new PDPage(PDRectangle.A4);
            PDPage page2 = new PDPage(PDRectangle.A4);
            document.addPage(page1);
            document.addPage(page2);

            PDAcroForm form = new PDAcroForm(document);
            document.getDocumentCatalog().setAcroForm(form);

            // The default resources name the font that the default appearance string refers to.
            PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
            PDResources resources = new PDResources();
            resources.put(COSName.getPDFName("Helv"), helvetica);
            form.setDefaultResources(resources);
            form.setDefaultAppearance("/Helv 0 Tf 0 g");

            // 1. A text field with a single widget.
            PDTextField name = new PDTextField(form);
            name.setPartialName("fullName");
            form.getFields().add(name);
            addWidget(name.getWidgets().get(0), page1, new PDRectangle(150, 700, 250, 22));
            name.setValue("Ada Example");

            // 2. A check box: the two appearance states are drawn by hand.
            PDCheckBox newsletter = new PDCheckBox(form);
            newsletter.setPartialName("newsletter");
            form.getFields().add(newsletter);
            PDAnnotationWidget box = newsletter.getWidgets().get(0);
            addWidget(box, page1, new PDRectangle(150, 660, 18, 18));
            box.setAppearance(appearance(document, 18, "Yes", true));
            newsletter.check();

            // 3. A radio group: one field, two widgets, one export value per widget.
            PDRadioButton plan = new PDRadioButton(form);
            plan.setPartialName("plan");
            plan.setRadiosInUnison(true);
            form.getFields().add(plan);
            plan.setExportValues(List.of("basic", "premium"));
            PDAnnotationWidget basic = new PDAnnotationWidget();
            PDAnnotationWidget premium = new PDAnnotationWidget();
            addWidget(basic, page1, new PDRectangle(150, 620, 18, 18));
            addWidget(premium, page1, new PDRectangle(250, 620, 18, 18));
            basic.setAppearance(appearance(document, 18, "basic", false));
            premium.setAppearance(appearance(document, 18, "premium", false));
            plan.setWidgets(List.of(basic, premium));
            plan.setValue("premium");
            // A radio widget is invisible until its /AS names a state: set the unselected one explicitly.
            basic.getCOSObject().setName(COSName.AS, "Off");

            // 4. One field, two widgets on two pages: both always show the same value.
            PDTextField phone = new PDTextField(form);
            phone.setPartialName("contactPhone");
            form.getFields().add(phone);
            // Two fresh widgets, both pointing at the field through /Parent (do not reuse the merged one).
            PDAnnotationWidget first = new PDAnnotationWidget();
            PDAnnotationWidget second = new PDAnnotationWidget();
            first.setParent(phone);
            second.setParent(phone);
            addWidget(first, page1, new PDRectangle(150, 580, 150, 22));
            addWidget(second, page2, new PDRectangle(150, 780, 150, 22));
            phone.setWidgets(List.of(first, second));
            phone.setValue("+34 600 000 000");

            try (PDPageContentStream cs = new PDPageContentStream(document, page1)) {
                cs.beginText();
                cs.setFont(helvetica, 12);
                cs.newLineAtOffset(40, 706);
                cs.showText("Full name");
                cs.newLineAtOffset(0, -40);
                cs.showText("Newsletter");
                cs.newLineAtOffset(0, -40);
                cs.showText("Plan (basic, premium)");
                cs.newLineAtOffset(0, -40);
                cs.showText("Contact phone");
                cs.endText();
            }
            document.save(target.toFile());
        }
    }

    private static void addWidget(PDAnnotationWidget widget, PDPage page, PDRectangle rectangle)
            throws IOException {
        widget.setRectangle(rectangle);
        widget.setPage(page);
        widget.setPrinted(true);
        page.getAnnotations().add(widget);
    }

    /** Normal appearance with an "on" state named onName and an empty Off state. */
    private static PDAppearanceDictionary appearance(PDDocument document, float size, String onName,
                                                     boolean cross) throws IOException {
        COSDictionary states = new COSDictionary();
        states.setItem(COSName.getPDFName(onName), state(document, size, true, cross));
        states.setItem(COSName.OFF, state(document, size, false, cross));
        PDAppearanceDictionary dictionary = new PDAppearanceDictionary();
        dictionary.setNormalAppearance(new PDAppearanceEntry(states));
        return dictionary;
    }

    private static PDAppearanceStream state(PDDocument document, float size, boolean on, boolean cross)
            throws IOException {
        PDAppearanceStream stream = new PDAppearanceStream(document);
        stream.setBBox(new PDRectangle(size, size));
        stream.setResources(new PDResources());
        try (PDPageContentStream cs = new PDPageContentStream(document, stream)) {
            cs.setLineWidth(1);
            cs.addRect(0.5f, 0.5f, size - 1, size - 1);
            cs.stroke();
            if (on && cross) {
                cs.moveTo(3, 3);
                cs.lineTo(size - 3, size - 3);
                cs.moveTo(3, size - 3);
                cs.lineTo(size - 3, 3);
                cs.stroke();
            } else if (on) {
                cs.addRect(4, 4, size - 8, size - 8);
                cs.fill();
            }
        }
        return stream;
    }

    public static void main(String[] args) throws IOException {
        create(Path.of(args[0]));
    }
}

The result, read back with the inspector, is one text field, one check box (Yes), one radio group with two widgets (value premium) and one text field contactPhone with two widgets, one on each of the two pages:

  Tx       fullName               value=Ada Example    widgets=1
  Btn      newsletter             value=Yes            widgets=1
  Btn      plan                   value=premium        widgets=2
  Tx       contactPhone           value=+34 600 000 000 widgets=2

Multi-widget fields

A field with several widgets stores its value once, so editing it on one page changes it on the other, which is what a customer number or a name repeated on every page needs. The two widgets can have different positions, sizes and colours. The pitfall found while writing the example: reusing the widget that new PDTextField(form) already created and adding a second one with setWidgets made PDFBox log "Child field is same object as parent" and turned the field into a group in the field tree. Creating two new widgets and giving each setParent(field), as the official example does, avoids it. The radio group above is the other kind of multi-widget field: each widget is one option, and the value says which one is on.

Choosing between the two tools

Need JasperReports properties PDFBox

Fields that follow the layout of a data-driven report

Yes: position, font and colours come from the element

Manual: every rectangle is computed in code

Text, combo, list, check box, radio

Yes

Yes

Push button, signature field, required flag, max length, JavaScript

No

Yes (fields and flags); actions through the annotation and action classes

One field in several places

No (one widget per element; a shared name only groups radio options)

Yes (several widgets with the same parent)

Fill, read back and flatten an existing file

No

Yes

Licence

LGPL-3.0 (Community)

Apache-2.0

For the Bookshelf onboarding form the two are combined: JasperReports lays out the page and declares the fields, PDFBox pre-fills the values that the back office knows, adds the signature field and, after the customer has returned the file, reads the values back with the inspector’s approach. iText (AGPL or commercial) can do the same end to end with a single library, as shown in Editable Forms with iText.

What was run, and what was not

  • The JRXML template and the six Java classes on this page were compiled with maven.compiler.release 21 against JasperReports 7.0.8, jasperreports-pdf 7.0.8, jasperreports-jdt 7.0.8 and PDFBox 3.0.8, and run on a local JDK.

  • The exported form, the filled form, the flattened form, the generated form and the form with a signature field were opened with PDFBox, the field values and flags were read back, and three of them were also read with pypdf and rendered to PNG with PDFBox to check how they look.

  • Not tested: opening the files in Adobe Acrobat, a browser viewer or a mobile viewer (appearance of the generated glyphs, auto font size and JavaScript-free behaviour can differ); a multi-select list; a signature applied to a form; PDF/A or tagged output of a form (JasperReports tagging needs the Professional edition in 7.0.8 and PDF/A forbids some field features, see PDF Standards & Conformance); accessibility of the fields (field labels as tooltips, /TU, are not set by the exporter properties above).

  • The check box on-state name, the radio on-state names and the first-choice default of a list are observations of this run, not documented behaviour.

Outdated points to avoid

  1. Do not use PDDocument.load or PDDocument.loadNonSeq. PDFBox 3 loads with Loader.loadPDF(File | byte[] | RandomAccessRead).

  2. Do not rely on setNeedAppearances(true) to make values appear. It asks a viewer to regenerate appearances, and many viewers and printers do not. setValue already writes the appearance, and a flattened file needs it.

  3. Do not build the radio value from a template UUID. The on-state names JasperReports generates change on each export; read them from the widget.

  4. Do not assume JasperReports can produce every field. Push buttons and signature fields are added with PDFBox, and the properties exist only since 6.12.0 (finding O27).

  5. Do not use XFA for new forms. It is deprecated in PDF 2.0 and not a target of this page.

  6. Do not treat a read-only flag as protection. Any code can still change the value.