Page Events: Headers, Footers & Watermarks

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.

Almost every business document repeats something on each page: a letterhead, the invoice number, "Page 2 of 5", a "DRAFT" stamp behind the text. You cannot add these with ordinary layout elements, because the content of the page is not known when you write the code: you do not know how many pages the invoice will take, nor where one page ends. iText solves this with page events. The PdfDocument announces that a page is about to start, or has just ended, and an event handler that you registered draws what it wants on that page. This page shows how to write such a handler in iText 9.8.0 (as of 2026-10-09, on Java 21), how to implement the classic "page X of Y" with a placeholder that is filled in before the document closes, how to put backgrounds and watermarks under or over the content, and how to build a table of contents with the cooperation of events and renderers. It continues the Bookshelf Billing invoice used by the other iText pages of this section. The document, writer and layout objects used here are introduced in Document Lifecycle, the fonts in Fonts & Unicode, and the images in Colours, Images & SVG. Every example below was compiled and run against iText 9.8.0, and the resulting files were opened and their text extracted to check the header, the footer numbers, the watermark and the table of contents.

What changed since PdfPageEventHelper and HeaderFooter

Books written for iText 1 and 2, among them the 2006 text iText in Action that was consulted for the order of concepts of this page and is not reproduced, decorate pages in two ways. The simple one is the HeaderFooter class, set on the Document with a phrase and a "numbered" flag. The flexible one is PdfPageEventHelper: you subclass it, override onOpenDocument, onStartPage, onEndPage and onCloseDocument, and register it with PdfWriter.setPageEvent. Neither exists in iText 7, 8 or 9 (finding O9 of this section’s source analysis, and for HeaderFooter, the page-decoration part of finding O5). The current approach is:

  • There is no HeaderFooter. A header or a footer is something you draw yourself, from an event handler, using the same canvas objects as the rest of the library.

  • The helper class is replaced by com.itextpdf.kernel.pdf.event.AbstractPdfDocumentEventHandler. You subclass it and implement a single method, onAcceptedEvent, and you register the handler on the PdfDocument for one or more event types with addEventHandler. In iText 7 and 8 you implemented the interface com.itextpdf.kernel.events.IEventHandler and the event class was Event. iText 9.0 removed that interface from the kernel module: it was renamed and moved to AbstractPdfDocumentEventHandler, Event became AbstractPdfDocumentEvent, and PdfDocumentEvent moved to the kernel.pdf.event package. Code written for iText 7 or 8 therefore changes from implements IEventHandler to extends AbstractPdfDocumentEventHandler, and the import of the event types changes with it. The official list of these changes is in the Breaking changes for iText 9 page of the knowledge base. Many tutorials and answers on the web still show the old form, and so do some pages of the iText knowledge base that were written for iText 7: where they differ from the code below, the code below is the one that was compiled against 9.8.0.

  • There is no per-writer page event. Handlers are registered on the PdfDocument, which is the object that knows about pages, so they work the same whether you build a document with the layout Document, with the low-level canvas, or in stamping mode.

  • There is no onOpenDocument callback, and onCloseDocument has no direct equivalent on a handler class: the nearest is the START_DOCUMENT_CLOSING event type listed below. This page still does the closing work (writing the total number of pages) explicitly in your code, just before the document is closed, which keeps the order visible.

The licence is the one of the rest of iText: the AGPLv3 or a commercial licence. All classes used on this page are in the kernel and layout modules of iText Core, so no extra module is needed.

The event model

A PdfDocument dispatches events to the handlers registered for the event type. PdfDocumentEvent defines these types (as string constants):

  • START_PAGE: a new page was created and is still empty. It is fired when you call pdf.addNewPage() and also when the layout engine starts a new page because the previous one is full.

  • END_PAGE: a page is finished and is about to be flushed. It is fired when the layout moves on to the next page, and for the last page it is fired while the document closes.

  • INSERT_PAGE and REMOVE_PAGE: a page was added at a given position, or was removed. These are useful when you manipulate an existing document (see the page on stamping in this section).

  • START_DOCUMENT_CLOSING and START_WRITER_CLOSING: the document, or its writer, is about to be closed.

A handler receives an AbstractPdfDocumentEvent. For the page events you cast it to PdfDocumentEvent, which gives you getDocument() and getPage(). The page is where you draw: you create a PdfCanvas on it, or a layout Canvas on top of that, and the page number comes from pdf.getPageNumber(page). A handler can be registered for several types; the same handler instance receives them all, and you can tell them apart with event.getType(). To stop a handler, call pdf.removeEventHandler(handler). Since 9.0 this takes the handler instead of a type and a handler.

The two events differ in a way that matters for what you draw. START_PAGE is fired before any content is placed on the page, so anything you draw there goes into the content stream first and ends up behind the text and tables: it is the right place for backgrounds and watermarks. END_PAGE is fired after the layout has finished the page, so you know which page number it is and the content has been placed: it is the place for headers, footers and anything that must go over the content. If you need to guarantee the stacking order whatever the event, use page.newContentStreamBefore() (below all content) or page.newContentStreamAfter() (above all content) to obtain the stream that you pass to the PdfCanvas; the examples do that.

One practical consequence: the layout engine does not know about your header. If the header takes space, you must reserve it with the page margins of the Document, as the first example does with document.setMargins(54, 36, 54, 36). The handler draws inside the margins, the layout fills the rest.

Headers and footers with page X of Y

"Page 2 of 5" cannot be written when page 2 is created, because the 5 is not known yet. The idea that survives from the old days is a placeholder: you reserve an empty box where the total goes, and fill it once you know it. In iText 9 the placeholder is a PdfFormXObject, a reusable chunk of drawing that you create once and show on every page with PdfCanvas.addXObjectAt. Because every page refers to the same object, drawing into it once, later, makes the total appear on all pages at the same time. The steps are shown in the figure.

Sequence of a page X of Y footer: the handler is registered, each END_PAGE draws Page n of and the empty placeholder, the total is written into the placeholder before the document is closed, and the last END_PAGE runs while closing

The first example is a complete program. It registers a handler for END_PAGE, draws a header with a rule on the left and a centred footer made of the text "Page n of " followed by the placeholder, and then fills the placeholder with the number of pages. Add iText Core as a POM aggregator plus one Bouncy Castle adapter (the adapter is only needed for encryption and signing, and is restated here so the coordinates are complete on this page); iText is licensed under the AGPLv3 or under a commercial licence.

<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>
package com.example.billing;

import com.itextpdf.io.font.constants.StandardFonts;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.PageSize;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfPage;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;
import com.itextpdf.kernel.pdf.event.AbstractPdfDocumentEvent;
import com.itextpdf.kernel.pdf.event.AbstractPdfDocumentEventHandler;
import com.itextpdf.kernel.pdf.event.PdfDocumentEvent;
import com.itextpdf.kernel.pdf.xobject.PdfFormXObject;
import com.itextpdf.layout.Canvas;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Paragraph;
import com.itextpdf.layout.properties.TextAlignment;

import java.io.IOException;

public class HeaderFooterExample {

    /** Draws a header and a "Page n of m" footer on every page. */
    static class HeaderFooterHandler extends AbstractPdfDocumentEventHandler {
        private static final float SIDE = 30;     // width of the placeholder for the total
        private static final float FONT_SIZE = 9;

        private final String header;
        private final PdfFont font;
        private final PdfFormXObject placeholder;

        HeaderFooterHandler(String header, PdfFont font) {
            this.header = header;
            this.font = font;
            this.placeholder = new PdfFormXObject(new Rectangle(0, 0, SIDE, FONT_SIZE * 1.5f));
        }

        @Override
        protected void onAcceptedEvent(AbstractPdfDocumentEvent event) {
            PdfDocumentEvent docEvent = (PdfDocumentEvent) event;
            PdfDocument pdf = docEvent.getDocument();
            PdfPage page = docEvent.getPage();
            int pageNumber = pdf.getPageNumber(page);
            Rectangle box = page.getPageSize();

            PdfCanvas canvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), pdf);
            // Header: invoice title on the left, a rule under it
            canvas.beginText().setFontAndSize(font, FONT_SIZE)
                    .moveText(box.getLeft() + 36, box.getTop() - 24)
                    .showText(header).endText();
            canvas.moveTo(box.getLeft() + 36, box.getTop() - 30)
                    .lineTo(box.getRight() - 36, box.getTop() - 30).stroke();

            // Footer: "Page n of " followed by the placeholder, centred as a whole
            String prefix = "Page " + pageNumber + " of ";
            float prefixWidth = font.getWidth(prefix, FONT_SIZE);
            float x = (box.getWidth() - prefixWidth - font.getWidth("00", FONT_SIZE)) / 2;
            canvas.beginText().setFontAndSize(font, FONT_SIZE)
                    .moveText(x, box.getBottom() + 24).showText(prefix).endText();
            canvas.addXObjectAt(placeholder, x + prefixWidth, box.getBottom() + 24 - 2.5f);
            canvas.release();
        }

        /** Call once, after all content was added and before the document is closed. */
        void writeTotal(PdfDocument pdf) {
            PdfCanvas canvas = new PdfCanvas(placeholder, pdf);
            try (Canvas layoutCanvas = new Canvas(canvas, new Rectangle(0, 0, SIDE, FONT_SIZE * 1.5f))) {
                layoutCanvas.add(new Paragraph(String.valueOf(pdf.getNumberOfPages()))
                        .setFont(font).setFontSize(FONT_SIZE).setMargin(0)
                        .setTextAlignment(TextAlignment.LEFT));
            }
        }
    }

    public static void main(String[] args) throws IOException {
        try (PdfDocument pdf = new PdfDocument(new PdfWriter("header-footer.pdf"))) {
            PdfFont font = PdfFontFactory.createFont(StandardFonts.HELVETICA);
            HeaderFooterHandler handler = new HeaderFooterHandler("Bookshelf Billing - Invoice 2026-0001", font);
            pdf.addEventHandler(PdfDocumentEvent.END_PAGE, handler);

            try (Document document = new Document(pdf, PageSize.A4)) {
                document.setMargins(54, 36, 54, 36);
                for (int i = 1; i <= 120; i++) {
                    document.add(new Paragraph("Line %d of the invoice: Book number %d".formatted(i, i)).setFont(font));
                }
                // All content is laid out; the pages exist, so the total is known. Fill the placeholder
                // before the document is closed at the end of this block.
                handler.writeTotal(pdf);
            }
        }
    }
}

Points worth noting in this example:

  • The handler class extends AbstractPdfDocumentEventHandler and is registered with pdf.addEventHandler(PdfDocumentEvent.END_PAGE, handler). Registering with the type is what filters the events: the handler is only called for the types it was registered for.

  • The placeholder is created with the final size of the digits, here 30 points wide, because a form XObject has a fixed bounding box: anything drawn outside is clipped. Five digits at 9 points fit comfortably in 30 points. If the total can be larger, make the box wider and align the text to the left, as the example does with TextAlignment.LEFT.

  • The width of the whole footer is calculated with font.getWidth(text, size), so the footer is centred as a whole even though the "of Y" part is added later. The reserved width is that of two digits ("00"); a document of 100 pages or more would be off centre by a few points, which is why a right-aligned or left-aligned footer is more common for this layout.

  • writeTotal opens a PdfCanvas on the form XObject itself, wraps it in a layout Canvas and adds a Paragraph. This is the same mechanism as drawing fixed areas on a page. Try-with-resources closes the Canvas, which is what flushes the layout output into the XObject.

  • writeTotal is called after the last document.add, as the last statement of the try block that opens the Document, so it runs before try-with-resources closes the document. At that point the layout has placed all the content, so pdf.getNumberOfPages() is final. The last page has not been "ended" yet: its END_PAGE runs while the document closes and adds the placeholder to the last page, so the "5" shows up there too. After the document is closed the placeholder is flushed and cannot be written to any more.

  • page.newContentStreamAfter() makes the header and footer a separate content stream that is painted after the page content, so the footer is never covered by a table that reaches the bottom margin.

  • The handler holds state (the font and the placeholder), so it is a normal class; for the data that you feed it (header text, logo, locale), prefer an immutable record that you pass to the constructor.

If the header has to change from section to section (the running head of a chapter, the customer name on a multi-customer batch), keep the current value in a field of the handler that your code updates, for example handler.setSection("Taxes") before adding the section, and read it in onAcceptedEvent. Remember that END_PAGE runs after the layout of the page, so a header that depends on the content of the page must be recorded while the content is laid out, for example from a custom renderer as the table of contents example does. A header that depends on the document, such as the invoice number, is simply a constructor argument.

Official documentation: the Page events for headers and footers and Header and footer examples pages of the knowledge base describe the same technique, and the API reference of AbstractPdfDocumentEventHandler and PdfFormXObject lists their methods.

Page numbers when pages move

The number printed by END_PAGE is the position of the page at the time of the event. If you later insert, remove or move pages (for example, to bring a table of contents to the front, as the last example does), the numbers that were already drawn do not change. Either finish the page order before the events run, or draw the page numbers in a second pass, in stamping mode (see the page on manipulating existing PDFs in this section), where the final order is known. A related caution for PDF/A documents: the placeholder technique draws text with the same font as the rest of the file, so the font must be embedded as the standard requires; the PDF/A page of this section covers this.

Backgrounds and watermarks

A watermark is the same mechanism with a different event and different drawing operators. The second example registers two handlers. The first runs on START_PAGE, fills the page with a pale colour, and draws the word "DRAFT" at 45 degrees in a large grey font with 15 % opacity; both go to a content stream placed before the page content, so the text of the invoice stays fully readable above them. The second runs on END_PAGE and puts a small semi-transparent logo in the bottom right corner, above the content. The logo is generated in memory so that the example needs no file; in your service you would load it from the classpath with ImageDataFactory.create(path).

package com.example.billing;

import com.itextpdf.io.font.constants.StandardFonts;
import com.itextpdf.io.image.ImageData;
import com.itextpdf.io.image.ImageDataFactory;
import com.itextpdf.kernel.colors.ColorConstants;
import com.itextpdf.kernel.colors.DeviceRgb;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.PageSize;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfPage;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;
import com.itextpdf.kernel.pdf.extgstate.PdfExtGState;
import com.itextpdf.kernel.pdf.event.AbstractPdfDocumentEvent;
import com.itextpdf.kernel.pdf.event.AbstractPdfDocumentEventHandler;
import com.itextpdf.kernel.pdf.event.PdfDocumentEvent;
import com.itextpdf.layout.Canvas;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Paragraph;
import com.itextpdf.layout.properties.HorizontalAlignment;
import com.itextpdf.layout.properties.TextAlignment;
import com.itextpdf.layout.properties.VerticalAlignment;

import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;

public class WatermarkExample {

    /** START_PAGE: a pale background and a diagonal text watermark, both below the page content. */
    static class BackgroundAndWatermarkHandler extends AbstractPdfDocumentEventHandler {
        private final String text;
        private final PdfFont font;
        private final PdfExtGState half = new PdfExtGState().setFillOpacity(0.15f);

        BackgroundAndWatermarkHandler(String text, PdfFont font) {
            this.text = text;
            this.font = font;
        }

        @Override
        protected void onAcceptedEvent(AbstractPdfDocumentEvent event) {
            PdfDocumentEvent docEvent = (PdfDocumentEvent) event;
            PdfDocument pdf = docEvent.getDocument();
            PdfPage page = docEvent.getPage();
            Rectangle box = page.getPageSize();

            // newContentStreamBefore() puts the drawing under whatever is added later
            PdfCanvas under = new PdfCanvas(page.newContentStreamBefore(), page.getResources(), pdf);
            under.saveState()
                    .setFillColor(new DeviceRgb(250, 247, 238))
                    .rectangle(box).fill()
                    .restoreState();

            under.saveState().setExtGState(half);
            try (Canvas canvas = new Canvas(under, box)) {
                canvas.showTextAligned(new Paragraph(text).setFont(font).setFontSize(72)
                                .setFontColor(ColorConstants.GRAY),
                        box.getWidth() / 2, box.getHeight() / 2, pdf.getPageNumber(page),
                        TextAlignment.CENTER, VerticalAlignment.MIDDLE, (float) Math.toRadians(45));
            }
            under.restoreState();
            under.release();
        }
    }

    /** END_PAGE: a small logo watermark above the content, in the bottom right corner. */
    static class LogoHandler extends AbstractPdfDocumentEventHandler {
        private final ImageData logo;

        LogoHandler(ImageData logo) {
            this.logo = logo;
        }

        @Override
        protected void onAcceptedEvent(AbstractPdfDocumentEvent event) {
            PdfDocumentEvent docEvent = (PdfDocumentEvent) event;
            PdfPage page = docEvent.getPage();
            Rectangle box = page.getPageSize();
            PdfCanvas over = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), docEvent.getDocument());
            over.saveState().setExtGState(new PdfExtGState().setFillOpacity(0.5f));
            over.addImageFittedIntoRectangle(logo, new Rectangle(box.getRight() - 90, box.getBottom() + 12, 54, 54), false);
            over.restoreState();
            over.release();
        }
    }

    static byte[] generateLogoPng() throws IOException {
        BufferedImage image = new BufferedImage(120, 120, BufferedImage.TYPE_INT_ARGB);
        Graphics2D g = image.createGraphics();
        g.setColor(new Color(31, 95, 166));
        g.fillOval(4, 4, 112, 112);
        g.setColor(Color.WHITE);
        g.fillRect(36, 40, 48, 40);
        g.dispose();
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        ImageIO.write(image, "png", out);
        return out.toByteArray();
    }

    public static void main(String[] args) throws IOException {
        try (PdfDocument pdf = new PdfDocument(new PdfWriter("watermark.pdf"));
             Document document = new Document(pdf, PageSize.A4)) {
            PdfFont bold = PdfFontFactory.createFont(StandardFonts.HELVETICA_BOLD);
            pdf.addEventHandler(PdfDocumentEvent.START_PAGE, new BackgroundAndWatermarkHandler("DRAFT", bold));
            pdf.addEventHandler(PdfDocumentEvent.END_PAGE, new LogoHandler(ImageDataFactory.create(generateLogoPng())));

            for (int i = 1; i <= 80; i++) {
                document.add(new Paragraph("Invoice line " + i).setHorizontalAlignment(HorizontalAlignment.LEFT));
            }
        }
    }
}

Notes on the example:

  • Transparency is an extended graphics state, PdfExtGState: setFillOpacity applies to fills, text and images, and setStrokeOpacity to strokes. It is set between saveState() and restoreState() so that it does not leak to the rest of the page. A new PdfExtGState per page is wasteful, so the first handler keeps one in a field and reuses it.

  • The text is placed with Canvas.showTextAligned, which accepts the page number, the horizontal and vertical alignment and a rotation angle in radians. The point (width / 2, height / 2) is the centre of the page, and the coordinate system has its origin at the bottom left corner of the page (the page on low-level canvas in this section explains the coordinate system and the transformation matrix).

  • addImageFittedIntoRectangle scales the image to fill the rectangle exactly; it does not keep the aspect ratio, so an image whose proportions differ from the rectangle’s is stretched (the logo here is square, like its 54 by 54 rectangle). The last parameter says whether the image is added inline in the content stream (true) or as an XObject that is stored once and reused (false, as here, the right choice for a logo repeated on every page).

  • A background drawn as a filled rectangle covers the whole page and its cost is a single operator. A background image works the same: call addImageFittedIntoRectangle with the page size, using an image with the page’s proportions. For letterhead on the first page only, check pdf.getPageNumber(page) == 1 in the handler.

  • A tiling or repeated watermark is a loop around showTextAligned, or better, a tiling pattern (see the pattern colours in Colours, Images & SVG).

  • The handlers run for every page, including the pages that a table adds when it spans several pages. That is the reason to prefer events over adding a watermark element to the document: an element is added once, in one place in the flow.

A word of caution about the meaning of a watermark. It is drawn content, and anybody with a PDF editor can delete it. If the "DRAFT" stamp must not be removable, or if the document must not be changed at all, sign the file (see the digital signature pages of this section) or encrypt it with permissions; the watermark alone is a visual hint, not a control. In the same way, a watermark added in stamping mode to an existing PDF (for instance to stamp a received PDF with the date of reception) uses the same canvas code with a handler on the stamped PdfDocument, or with a plain loop over pdf.getNumberOfPages() that creates a PdfCanvas on each page.

Official documentation: the Watermark examples and Adding backgrounds using page events pages of the knowledge base, and the API reference of PdfExtGState and Canvas.

A table of contents

A table of contents needs a fact that only the layout engine knows: on which page each heading ended up. Events alone cannot tell, because END_PAGE carries the page but not the elements that were placed on it. The usual solution is a small team of two: the renderer of each heading records its page when it is laid out, and the document then writes the table of contents once everything has been placed. The TocExample class does this with the following steps:

  1. Each heading is a Paragraph with a destination name (setDestination) and a custom renderer (a subclass of ParagraphRenderer). Renderers are explained in the page on renderers and custom layout of this section; here the only thing you need to know is that layout() is called when the element is placed and that the area it is placed in knows its page number. Because a paragraph can be laid out several times (for example when it does not fit and is tried again on the next page), the renderer removes its previous entry before adding the new one, and implements getNextRenderer() so that the copy iText makes when a paragraph is split still records itself.

  2. After the last chapter the code adds a page break and the table of contents, one Paragraph per entry with a right-aligned, dotted tab stop (TabStop, TabAlignment.RIGHT, DottedLine) between title and page number, and an action PdfAction.createGoTo(destination) that turns the whole line into a link.

  3. The contents page, created last, is moved to the front with pdf.movePage. Moving shifts every following page by the number of contents pages, so the number shown is page + tocPages. If the contents can run to several pages, lay it out once to learn how many pages it needs (for example into a throwaway PdfDocument in memory), or reserve a fixed number of pages for it.

package com.example.billing;

import com.itextpdf.io.font.constants.StandardFonts;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.PageSize;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.kernel.pdf.action.PdfAction;
import com.itextpdf.kernel.pdf.canvas.draw.DottedLine;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.AreaBreak;
import com.itextpdf.layout.element.Paragraph;
import com.itextpdf.layout.element.Tab;
import com.itextpdf.layout.element.TabStop;
import com.itextpdf.layout.element.Text;
import com.itextpdf.layout.layout.LayoutContext;
import com.itextpdf.layout.layout.LayoutResult;
import com.itextpdf.layout.properties.AreaBreakType;
import com.itextpdf.layout.properties.TabAlignment;
import com.itextpdf.layout.renderer.IRenderer;
import com.itextpdf.layout.renderer.ParagraphRenderer;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

public class TocExample {

    record TocEntry(String title, String destination, int page) { }

    /** Records on which page each heading really landed, once layout has placed it. */
    static class HeadingRenderer extends ParagraphRenderer {
        private final String title;
        private final String destination;
        private final List<TocEntry> entries;

        HeadingRenderer(Paragraph modelElement, String title, String destination, List<TocEntry> entries) {
            super(modelElement);
            this.title = title;
            this.destination = destination;
            this.entries = entries;
        }

        @Override
        public LayoutResult layout(LayoutContext layoutContext) {
            LayoutResult result = super.layout(layoutContext);
            if (result.getStatus() != LayoutResult.NOTHING) {
                entries.removeIf(e -> e.destination().equals(destination));
                entries.add(new TocEntry(title, destination, layoutContext.getArea().getPageNumber()));
            }
            return result;
        }

        @Override
        public IRenderer getNextRenderer() {
            return new HeadingRenderer((Paragraph) modelElement, title, destination, entries);
        }
    }

    static Paragraph heading(String title, String destination, PdfFont bold, List<TocEntry> entries) {
        Paragraph p = new Paragraph(title).setFont(bold).setFontSize(16).setDestination(destination);
        p.setNextRenderer(new HeadingRenderer(p, title, destination, entries));
        return p;
    }

    public static void main(String[] args) throws IOException {
        List<TocEntry> entries = new ArrayList<>();
        try (PdfDocument pdf = new PdfDocument(new PdfWriter("toc.pdf"));
             Document document = new Document(pdf, PageSize.A4)) {
            PdfFont regular = PdfFontFactory.createFont(StandardFonts.HELVETICA);
            PdfFont bold = PdfFontFactory.createFont(StandardFonts.HELVETICA_BOLD);

            // Content first; the table of contents is generated afterwards and moved to the front
            String[] chapters = {"Customers", "Invoice lines", "Taxes", "Payments"};
            for (int c = 0; c < chapters.length; c++) {
                if (c > 0) {
                    document.add(new AreaBreak(AreaBreakType.NEXT_PAGE));
                }
                document.add(heading(chapters[c], "chapter-" + c, bold, entries));
                for (int i = 1; i <= 8; i++) {
                    document.add(new Paragraph("Text of chapter " + chapters[c] + ", paragraph " + i).setFont(regular));
                }
            }

            int tocStart = pdf.getNumberOfPages() + 1;
            document.add(new AreaBreak(AreaBreakType.NEXT_PAGE));
            document.add(new Paragraph("Table of contents").setFont(bold).setFontSize(18));
            int tocPages = 1;
            for (TocEntry entry : entries) {
                // +tocPages: the contents page shifts every page number that follows it
                int shown = entry.page() + tocPages;
                Paragraph line = new Paragraph()
                        .addTabStops(new TabStop(520, TabAlignment.RIGHT, new DottedLine()))
                        .add(new Text(entry.title()).setFont(regular))
                        .add(new Tab())
                        .add(new Text(String.valueOf(shown)).setFont(regular))
                        .setAction(PdfAction.createGoTo(entry.destination()));
                document.add(line);
            }
            for (int i = 0; i < tocPages; i++) {
                pdf.movePage(tocStart + i, 1 + i);
            }
        }
    }
}

The same data can feed the bookmarks panel of a PDF viewer: add PdfOutline objects from pdf.getOutlines(false) with the same destinations (the page on navigation and actions in this section covers outlines in depth). An event handler still has a role in a document like this: the header and footer handlers above run for the contents page too, and a START_PAGE handler is where you would stamp the "Contents" running head. What it must not do is write page numbers on pages that are later moved, as discussed above.

Official documentation: the Creating table of contents using events page of the knowledge base shows the same idea with event handlers and renderers, and Using renderers and event handlers explains the two mechanisms together. The API reference of ParagraphRenderer lists what can be overridden.

Java 21 idioms for event handlers (O28)

The old tutorials were written for Java 1.3 to 5: anonymous inner classes, no generics, StringBuffer and manual close() calls in finally blocks. The examples on this page use what a Java 21 service would use:

  • try-with-resources for PdfDocument and the layout Document (and for the layout Canvas that fills the placeholder), so the file is closed, and the placeholder flushed, even when an exception occurs.

  • record types for the data that a handler needs (TocEntry), because the entries are immutable values that are collected during layout and read afterwards.

  • String.formatted instead of String.format and concatenation.

  • Small static nested classes for the handlers, with their configuration in final fields set from the constructor, so that a handler can be reused for several documents only if it is stateless. A handler that holds a placeholder (as the footer handler does) belongs to one document and must not be shared between documents or between threads: create it per request, as you create the PdfDocument.

Thread safety follows from that last point. iText documents are not thread safe, and the handlers belong to a document, so in a Spring Boot service each request creates its own PdfDocument and handlers. Nothing is shared except immutable configuration and, if you want to save time, the parsed image data or font programs described in the pages on fonts and on testing and performance.

Common mistakes

  • Drawing the page number in START_PAGE: the page already has a number, but it is not final if you insert pages later, and anything drawn there goes behind the content. Use END_PAGE for numbers.

  • Forgetting to reserve space with the document margins, so the header overlaps the first line of the text.

  • Calling writeTotal after document.close(): the placeholder is already flushed and the write fails or has no effect. Call it before.

  • Using a new PdfFormXObject per page for the total: each one would need to be filled. One placeholder, shared by all pages, is filled once.

  • Looking for PdfPageEventHelper, HeaderFooter or setPageEvent on the writer: they were removed in iText 7, and the IEventHandler interface of iText 7 and 8 was removed in 9.0.

  • Mixing the coordinate system of the layout (which flows from the top) with the one of the canvas (origin at the bottom left): header positions are page.getPageSize().getTop() - offset.

What comes next

The renderer technique used for the table of contents is the general mechanism of the layout engine, described in the renderers and custom layout page of this section. The canvas operators and the graphics state used by the watermark are in the low-level canvas page, and the table borders that used to be drawn with table and cell events are drawn with renderers too, as described in Tables. The structure of the three objects that every example here depends on is in iText Architecture & Modules, and the full list of page and reading pages is on the section landing page.