Skip to main content

Generate PDF Invoices in Spring Boot: OpenPDF vs iText vs Flying Saucer (HTML to PDF)

One invoice built three ways in Spring Boot 4.1: OpenPDF 3.0.5, iText Core 9.8.0 (AGPL) and Flying Saucer 10.7.0 rendering a Thymeleaf template. Checks which one shapes Marathi and Hindi correctly (none by default), the OpenPDF switch that does, streaming vs buffered downloads, font subsetting and timings.

Somebody asks for “a PDF of the invoice”. The first version takes an afternoon in any library, and then the customer is called श्रीमती क्षमा कुलकर्णी and the PDF prints her name with its conjuncts broken apart. If you want to Spring Boot generate PDF files that include Marathi or Hindi, the library choice matters less than one question almost every tutorial skips: does anything in the pipeline actually shape Devanagari text? This post builds the same invoice three ways in one Spring Boot 4.1 module — OpenPDF’s programmatic API, iText Core’s programmatic API, and Flying Saucer rendering a Thymeleaf template — embeds a free Devanagari font in all three, and then looks at the PDFs. The short answer is in the table near the end: none of the three shapes Devanagari by default, and the way to make one of them do it has a trap of its own. Everything below comes from a run recorded in the companion module.
Tested against: Spring Boot 4.1.2 (latest GA), JDK 25.0.5, OpenPDF 3.0.5 (latest GA on Maven Central; package org.openpdf.text), iText Core 9.8.0 (latest GA; AGPL 3 or commercial), Flying Saucer flying-saucer-pdf 10.7.0 (latest GA, built against OpenPDF 3.0.5; the old flying-saucer-pdf-openpdf artifact stopped at 9.4.0 and its POM is a relocation to flying-saucer-pdf), Thymeleaf 3.1.5.RELEASE, Apache PDFBox 3.0.8 (tests), Apache FOP 2.11 (one experiment), Noto Sans Devanagari under the SIL Open Font License 1.1. Poppler’s pdftoppm and pdftotext did the rendering and extraction. Timings come from a shared 2-vCPU sandbox, one run per line: indicative only.

A PDF library does two jobs, and two of the three share one engine

A PDF is a page description: “draw these glyph numbers from this embedded font at these coordinates”. A library has to lay out your content (where does the table go, where does the line wrap) and then write the file (fonts, content streams, cross-reference table). OpenPDF and iText expose both through Java objects: you build a Document, add a table, add paragraphs. Flying Saucer takes a different route: you give it HTML and CSS, it does the layout with its own CSS engine, and it hands the drawing to OpenPDF.
InvoiceJava record (a) OpenPDF 3.0.5Document, PdfPTable, Phrase, Font (b) iText Core 9.8.0Document, Table, Paragraph, PdfFont Thymeleafinvoice.html Flying Saucer10.7.0 writes through OpenPDF invoice.pdfsame fonts, same text licence: LGPL 2.1 / MPL 2.0 licence: AGPL 3 or commercial licence: LGPL 2.1 or later Two of the three routes end in the same library; only iText is a separate code base with a different licence.
The diagram is the whole comparison in one picture. The two columns at the top and the bottom are different ways of describing the page; the bottom one shares its file writer with the top one. That matters twice later: a font problem in OpenPDF is a font problem in Flying Saucer too, and a fix inside OpenPDF may or may not reach through Flying Saucer’s layout.

One data record feeds all three, so the comparison is about the libraries. It is Invoice.java, with sample data in SampleInvoices.java: a Mumbai seller, a Pune buyer named in Marathi, four lines mixing Marathi, Hindi and English, 18% GST split as CGST and SGST. Every writer implements the same one-method interface, InvoiceWriter.java.

The smallest version: OpenPDF

OpenPDF is the maintained fork of the old iText 4 code (LGPL 2.1 and MPL 2.0, declared in its parent POM). In 3.0.5 the package is org.openpdf.text; older tutorials and Stack Overflow answers use com.lowagie.text and fail at the import line. The font is embedded with IDENTITY_H, which means the PDF stores glyph ids and only the glyphs actually used are embedded — a subset.

From src/main/java/com/ankurm/pdfinvoice/OpenPdfInvoiceWriter.java:

// IDENTITY_H + EMBEDDED: glyph ids are written, and only the glyphs used are embedded (a subset).
// The FOP engine re-reads the font by file name, so in FOP mode the font must be a real file.
BaseFont regular = shaping == Shaping.FOP
        ? BaseFont.createFont(FontBytes.tempFile(FontBytes.REGULAR).toString(), BaseFont.IDENTITY_H, BaseFont.EMBEDDED)
        : BaseFont.createFont("NotoSansDevanagari-Regular.ttf", BaseFont.IDENTITY_H,
                BaseFont.EMBEDDED, true, FontBytes.load(FontBytes.REGULAR), null);
BaseFont bold = shaping == Shaping.FOP
        ? BaseFont.createFont(FontBytes.tempFile(FontBytes.BOLD).toString(), BaseFont.IDENTITY_H, BaseFont.EMBEDDED)
        : BaseFont.createFont("NotoSansDevanagari-Bold.ttf", BaseFont.IDENTITY_H,
                BaseFont.EMBEDDED, true, FontBytes.load(FontBytes.BOLD), null);
body = new Font(regular, 10);
strong = new Font(bold, 10);
title = new Font(bold, 20);

The line-item table is a PdfPTable with weighted column widths; the cell helpers are in the same file. The rendered page is output/png/invoice-openpdf.png, and the PDF itself is output/pdf/openpdf.pdf (20,039 bytes).

Going deeper: what the writer does not close

The writer calls writer.setCloseStream(false) because it is handed a servlet output stream it does not own. Fonts are re-created on every call, which is simple and measurably cheap for a demo but is the first thing to cache in a service that renders thousands of invoices; the timings below include that cost. The Marathi labels (“कर चलन”, “ग्राहक”) sit next to English ones in the same Phrase because the single Noto font carries Latin glyphs as well, so no per-run font switching is needed.

The same invoice in iText Core 9, and the licence you have to read first

iText Core’s API is the better-designed of the two programmatic ones: a PdfWriter feeds a PdfDocument, a Document lays out Paragraph and Table elements, and the font is a PdfFont you set once on the document. The code is longer only because the invoice has a header table and a totals table.

From src/main/java/com/ankurm/pdfinvoice/ITextInvoiceWriter.java:

PdfFont regular = PdfFontFactory.createFont(FontBytes.load(FontBytes.REGULAR),
        PdfEncodings.IDENTITY_H, EmbeddingStrategy.FORCE_EMBEDDED);
PdfFont bold = PdfFontFactory.createFont(FontBytes.load(FontBytes.BOLD),
        PdfEncodings.IDENTITY_H, EmbeddingStrategy.FORCE_EMBEDDED);

regular.setSubset(subset);
bold.setSubset(subset);

PdfWriter writer = new PdfWriter(out);
writer.setCloseStream(false);
PdfDocument pdf = new PdfDocument(writer);
pdf.getDocumentInfo().setTitle("Invoice " + inv.number());
Document doc = new Document(pdf, PageSize.A4);
doc.setMargins(40, 40, 40, 40);
doc.setFont(regular).setFontSize(10);

The result is output/png/invoice-itext.png; the PDF is 23,344 bytes against OpenPDF’s 20,039; both are one page of about 20 KB.

Now the licence, because it is the real difference between these libraries. The facts below are read from the sources by scripts/licences.sh and saved in output/00-versions-and-licences.txt.

openpdf 3.0.5 (declared in openpdf-parent):     GNU Lesser General Public License (LGPL), Version 2.1;Mozilla Public License Version 2.0;
itext kernel/layout 9.8.0 (root pom):            GNU Affero General Public License v3;
flying-saucer-pdf 10.7.0 (flying-saucer-parent): GNU Lesser General Public License (LGPL), version 2.1 or later;
typography (pdfCalligraph) 5.1.1:           Proprietary License;

The iText Core POM declares the GNU Affero General Public License v3. On its AGPL page iText states that all versions from 5 on are “dual-licensed, and available under open source (AGPLv3) or commercial license agreements”, and spells out what the AGPL route asks of you in these words, copied from the page text saved in output/00-versions-and-licences.txt:

- If you cannot comply with the AGPLv3 terms, we offer commercial licensing options. Purchasing a commercial license will release you from the AGPLv3 copyleft restrictions, allowing you to keep your applications closed-source to your users.

- You may not deploy it on a network without disclosing the full source code of your own applications under the AGPL license. You must distribute all source code, including your own product and web-based applications.

- When using iText Core/Community under AGPL, you must prominently mention iText and include the iText copyright and AGPL license in output file metadata, and also retain the producer line in every PDF that is created or manipulated using iText.
The AGPL trap, stated as facts and not as advice. I am not a lawyer and this is not legal advice. The iText page says that using iText Core under the AGPL means you “may not deploy it on a network without disclosing the full source code of your own applications”, and that the commercial licence is how you “keep your applications closed-source to your users”. A Spring Boot service that renders invoices and is reachable over a network is exactly the shape of deployment that sentence is about. The same page says you must keep the producer line in every PDF; the file really does carry one (see below). The dependency is one line in a pom.xml and arrives without a prompt, which is why it gets pulled in by accident. Whether your project is affected, and what the AGPL requires of it, is a question for your own counsel.
Title:           Invoice INV-2026-0042
Producer:        iText® Core 9.8.0 (AGPL version) ©2000-2026 Apryse Group NV

That is the Producer line iText 9.8.0 wrote into output/pdf/itext.pdf, copied from output/03-pdffonts-and-producer.txt; OpenPDF writes OpenPDF 3.0.5 and Flying Saucer Flying Saucer 10.7.0 with OpenPDF 3.0.5 in the same field.

Going deeper: the Devanagari add-on is proprietary

iText sells the shaping engine as a separate add-on, pdfCalligraph; its product page lists Hindi and Marathi among the supported languages. The POM of the typography artifact (5.1.1 on iText’s own repository, not on Maven Central) declares Proprietary License and depends on iText’s licensing library. I did not run it: it needs a licence key, and testing a paid add-on without one would not tell you what it does. So for iText the honest result in this post is “iText Core on its own does not shape Devanagari”, nothing more.

Flying Saucer: write the invoice as HTML and CSS

If your invoice already exists as an HTML email or a web page, or a designer owns it, laying it out in CSS is far more pleasant than computing coordinates in Java. Flying Saucer renders a strict subset: the input must be well-formed XHTML, the CSS I used was plain CSS 2.1 plus @page; I would not expect flexbox or grid to work and did not try them. Thymeleaf produces the XHTML string; Flying Saucer parses it as XML.
invoice.html+ Invoice XHTML stringmust be well-formed CSS layoutboxes, tables, pages OpenPDFshowText, fonts PDF Text is measured by Flying Saucer (layout box) and drawn by OpenPDF (showText). Whatever either one does to a Devanagari string, the other does not know about: layout measures glyph widths one code point at a time. Thymeleaf 3.1 runs in the first box; calling a method on a java.* object from a template expression is refused.
The pipeline is short, and the last sentence on the diagram is the one to remember: Flying Saucer measures text and OpenPDF draws it, and neither knows what the other did with a Devanagari string. We will see that matter in the shaping section.

The template is src/main/resources/templates/invoice.html; the part that matters is the font declaration and one table row:

body { font-family: 'Noto Sans Devanagari'; font-size: 10pt; color: #000; }
...
<tr th:each="l : ${inv.lines}">
  <td th:text="${l.description}">item</td>
  <td class="r" th:text="${l.quantity}">1</td>
  <td class="r" th:text="${money.inr(l.unitPrice)}">1</td>
  <td class="r" th:text="${money.inr(l.amount)}">1</td>
</tr>

And the writer, from src/main/java/com/ankurm/pdfinvoice/FlyingSaucerInvoiceWriter.java. Flying Saucer takes fonts as a file path, so the module copies the two Noto files from the classpath to a temp directory at start-up:

private void render(Invoice inv, OutputStream out) throws Exception {
    ITextRenderer renderer = new ITextRenderer();
    renderer.getFontResolver().addFont(fontDir.resolve("NotoSansDevanagari-Regular.ttf").toString(),
            "Noto Sans Devanagari", BaseFont.IDENTITY_H, BaseFont.EMBEDDED, null);
    renderer.getFontResolver().addFont(fontDir.resolve("NotoSansDevanagari-Bold.ttf").toString(),
            "Noto Sans Devanagari", BaseFont.IDENTITY_H, BaseFont.EMBEDDED, null);
    if (!subset || shaped) {
        for (FontFamily family : renderer.getFontResolver().getFonts().values()) {
            if (!"Noto Sans Devanagari".equalsIgnoreCase(family.getName())) {
                continue;   // the built-in Helvetica/Serif families are in the same map
            }
            for (FontDescription d : family.getFontDescriptions()) {
                d.getFont().setSubset(subset);
                if (!shaped) {
                    continue;
                }
                String file = d.getWeight() >= 700 ? "NotoSansDevanagari-Bold.ttf" : "NotoSansDevanagari-Regular.ttf";
                LayoutProcessor.loadFont(d.getFont(), fontDir.resolve(file).toString());
            }
        }
    }
    renderer.setDocumentFromString(html(inv));
    renderer.layout();
    renderer.createPDF(out, false);   // false: do not close the servlet output stream
    renderer.finishPDF();
}
Thymeleaf 3.1 refuses to call methods on java.* objects. My first template passed a DecimalFormat and called ${inr.format(l.unitPrice)}. The page failed with Calling method ‘format’ is forbidden for type ‘class java.text.DecimalFormat’ in this expression context. The fix is a small class of your own, Money.java, exposed to the template as ${money.inr(x)}, and it is also what keeps the three writers formatting rupees identically.
Going deeper: which Flying Saucer artifact, and why

Most tutorials still show flying-saucer-pdf-openpdf. Maven Central’s metadata for it ends at 9.4.0 (last updated 2023-12-30) and the 9.4.0 POM contains a <relocation> to org.xhtmlrenderer:flying-saucer-pdf. That artifact, at 10.7.0 (metadata updated 2026-10-24), declares OpenPDF 3.0.5 as its dependency and is compiled with java.version 21 in its parent POM; the PDF it wrote here says “Flying Saucer 10.7.0 with OpenPDF 3.0.5”. There is also flying-saucer-pdf-itext5 (last 9.7.2), which targets iText 5; I did not test it. The class you use is still org.xhtmlrenderer.pdf.ITextRenderer.

The rendered page is output/png/invoice-flyingsaucer.png.

Marathi and Hindi: the PDF looks nearly right, and is wrong

Devanagari is not written one code point at a time. ज्ञ is stored as three code points — ज, the virama (halant) ्, and ञ — and must be drawn as one conjunct glyph. The short-i vowel sign ि is stored after its consonant but drawn before it. A reph (the र् in कर्म) is stored first and drawn as a mark above the following consonant. The font contains all the needed glyphs and the substitution rules (the OpenType GSUB table); something has to apply them. That something is a shaper, and a PDF library that writes “one glyph per code point” does not have one.
What is stored: code points, in the order you type them U+091C U+094D U+091E ja + virama + nya = the syllable ज्ञ (jna) kept as 3 code points in the string; the page needs to show 1 shape No shaper: 3 glyphs, one per code point glyph glyph glyph ja with a visible virama mark, then nya, side by side Shaper: GSUB substitutes one conjunct glyph 1 glyph (and the vowel sign moved before its consonant) this is the glyph-count drop in output/04-glyph-counts.txt
The diagram shows the first thing to look for: a conjunct that is three side-by-side shapes instead of one. To check it without trusting my eyes alone, the module has a probe invoice (?probe=true) whose only content is क्षत्रिय ज्ञान श्री द्वार कर्म कि प्रिय, and a second reference line drawn by a different shaper: Pillow with libraqm (HarfBuzz), using the same font file, from scripts/compare.py. I rendered every PDF to PNG with pdftoppm and looked at the images.
Six PDF renderings of the same Marathi and Hindi test string under a reference line: the three default engines show broken conjuncts, the two shaped variants match the reference

The picture is output/png/shaping-comparison.png. Top to bottom: the reference, OpenPDF, iText, Flying Saucer (all defaults), OpenPDF with Apache FOP on the classpath, OpenPDF with its GlyphLayoutManager, and Flying Saucer with OpenPDF’s LayoutProcessor switched on. What I saw: in the three default renderings क्ष and ज्ञ come out as a consonant, a visible halant and a second consonant, the ि of कि lands after its consonant, and the reph of कर्म sits beside the र instead of above. The two shaped renderings match the reference line.

A number backs the picture. A shaper merges code points into fewer glyphs, so the page content streams of the shaped PDFs contain fewer glyph codes. Counted by GlyphCountTest (all fonts are Identity-H, two bytes per glyph):
openpdf                  glyph codes: 427
itext                    glyph codes: 423
flyingsaucer             glyph codes: 421
openpdf-jdk              glyph codes: 387
openpdf-jdk-actualtext   glyph codes: 387
flyingsaucer-shaped      glyph codes: 381

From output/04-glyph-counts.txt. The defaults write 427, 423 and 421; the shaped variants 387 and 381. The absolute numbers include spaces and differ by layout details, so read the gap between the groups, not the digits.

Result, per library, with defaults: OpenPDF 3.0.5 — broken conjuncts. iText Core 9.8.0 — broken conjuncts. Flying Saucer 10.7.0 — broken conjuncts (it writes through OpenPDF). Nothing in the documentation path I followed warns you, because the text is all there: a spell-checker, a copy-paste and a unit test that extracts the text will all pass. Only your eyes, or a customer, see it.

Making OpenPDF shape Devanagari: three switches, one that works

OpenPDF 3.0.5 ships three mechanisms in its jar. I tried all three on the same invoice.

1. GlyphLayoutManager (works). OpenPDF can hand a string to java.awt.Font, whose text layout in the JDK uses a bundled HarfBuzz, and write the resulting glyph ids to the PDF. You load the font through the manager instead of BaseFont.createFont. From src/main/java/com/ankurm/pdfinvoice/OpenPdfInvoiceWriter.java:

if (shaping == Shaping.JDK || shaping == Shaping.JDK_ACTUAL_TEXT) {
    GlyphLayoutManager glm = new GlyphLayoutManager().setWriteActualText(shaping == Shaping.JDK_ACTUAL_TEXT);
    writer.setGlyphLayoutManager(glm);
    body = glm.loadFont("NotoSansDevanagari-Regular.ttf", new ByteArrayInputStream(FontBytes.load(FontBytes.REGULAR)), 10);
    strong = glm.loadFont("NotoSansDevanagari-Bold.ttf", new ByteArrayInputStream(FontBytes.load(FontBytes.BOLD)), 10);
    title = glm.loadFont("NotoSansDevanagari-Bold.ttf", new ByteArrayInputStream(FontBytes.load(FontBytes.BOLD)), 20);
}

The rendering matches the reference (the fifth row of the picture), and the glyph count drops as shown above. This needs the java.desktop module, which a normal JDK has; it ran here in a headless sandbox, because only font layout is used, not a display.

The price: the text layer breaks. A shaped glyph has no one-to-one path back to the code points that made it, so copy-paste, search and screen readers get the wrong text. PdfContentTest printed what PDFBox extracts from the shaped invoice, written to output/01-text-extraction.txt:

The shaped page without ActualText extracts From / ववकेता and गाहक where the page shows विक्रेता and ग्राहक. Switching on setWriteActualText(true) makes OpenPDF wrap each shaped run in PDF /ActualText marked content and fixes extraction: that is the openpdf-jdk-actualtext engine, and its extraction matched all eight Devanagari strings the test asserts.
openpdf-jdk:            From / ववकेता Bill to / गाहक
                        Item / तपशील Qty Rate Amount / रककम
openpdf-jdk-actualtext: From / विक्रेता  Bill to / ग्राहक
                        Item / तपशील Qty Rate Amount / रक्कम

2. Apache FOP on the classpath (did not help). OpenPDF has an optional dependency on org.apache.xmlgraphics:fop; with it present, TextRenderingOptions switches on a GSUB-based path. I added FOP 2.11 through the fop Maven profile of the module (the resolved classpath grew to 121 jars, Batik among them), asked for glyph substitution, and rendered both the invoice and a plain paragraph. In the invoice’s table cells the output was visually the same as with no FOP (the fourth row of the picture); in a plain paragraph in a scratch test the viramas disappeared from the text altogether. That scratch test is not in the module, so treat the second half as a warning rather than a result: the FOP route did not give me correct Devanagari in the invoice, and I would not ship it.

3. The static LayoutProcessor (works, with a global-state trap). This older mechanism is what makes Flying Saucer shape: you cannot give Flying Saucer a GlyphLayoutManager, but you can register the fonts Flying Saucer created with OpenPDF’s static LayoutProcessor. The sixth row of the picture is the result. It is JVM-wide and deprecated. Run while it is enabled, every other OpenPDF writer in the process fails, which the module records:

openpdf: RuntimeException: Font creation failed for NotoSansDevanagari-Regular.ttf. | root cause IOException: NotoSansDevanagari-Regular.ttf not found as file or resource.
openpdf-jdk: IllegalStateException: LayoutProcessor is enabled. Don't use the deprecated LayoutProcessor! | root cause IllegalStateException: LayoutProcessor is enabled. Don't use the deprecated LayoutProcessor!
after LayoutProcessor.disable(): openpdf-jdk rendered normally

That is output/05-layoutprocessor-trap.txt, written by LayoutProcessorTrapTest. The shaped Flying Saucer writer therefore takes a global lock and calls LayoutProcessor.disable() in a finally; that makes it safe against itself, not against another thread rendering with plain OpenPDF at the same moment. Also, Flying Saucer still measures text without shaping, so column widths and right alignment are computed from unshaped widths while the drawn text is shaped. On this invoice nothing visibly collided; a table with tight, centred Devanagari cells might.

Going deeper: other routes I did not run

These are things people suggest; none was run for this post, so treat them as leads only. OpenHTMLtoPDF is a Flying Saucer fork that draws with PDFBox and has an optional HarfBuzz-based text shaper. Rendering the same HTML in headless Chromium (print to PDF) or LibreOffice uses HarfBuzz and would shape correctly; neither was available as a Java library in this sandbox, and both put a process boundary into your service. Pre-rendering the Devanagari text as an image or SVG outline avoids fonts altogether and costs you selectable text. And you can avoid the problem entirely by using the Latin transliteration on an invoice, which is a business decision, not an engineering one.

The font: free, embedded, and subset

Noto Sans Devanagari is licensed under the SIL Open Font License 1.1 (the licence file travels with the font in the module). GitHub raw downloads were blocked in the sandbox, so the variable font came from the jsDelivr mirror of the google/fonts repository; scripts/make_fonts.py cuts static Regular and Bold instances from it, because OpenPDF and iText read only the default master of a variable font. Each file is about 218 KB. A PDF that embeds the whole font would be about that much bigger; all three libraries embed a subset instead, which pdffonts confirms:
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
QVECCU+NotoSansDevanagari-Bold       CID TrueType      Identity-H       yes yes yes      1  0
LWDRDP+NotoSansDevanagari-Regular    CID TrueType      Identity-H       yes yes yes      2  0

From output/03-pdffonts-and-producer.txt: emb yes, sub yes, a six-letter subset tag before the name. Re-rendering with subsetting turned off gives this (one render each, from output/06-bench.txt):

openpdf                    subset=20025 bytes, whole font=186300 bytes
itext                      subset=23330 bytes, whole font=200149 bytes
flyingsaucer               subset=19996 bytes, whole font=186308 bytes
openpdf-jdk-actualtext     subset=22268 bytes, whole font=187399 bytes
flyingsaucer-shaped        subset=21525 bytes, whole font=186691 bytes

Serving it: streaming a PDF, and what a failure looks like

A controller can return the PDF two ways. StreamingResponseBody hands you the servlet output stream and the library writes straight into it, with no whole-file buffer. The alternative is to render into a byte[] first. The difference is not memory; it is what happens when rendering fails half-way.
StreamingResponseBody (default) build document bytes written to the socket exception 200 and headers already sent here client has a 200 and a broken file Buffered (?mode=buffered) whole PDF into a byte[] (heap) exception nothing sent yet: a clean 500 If nothing fails, the buffered version also gets a Content-Length, so a browser can show a progress bar. An invoice is tens of kilobytes: buffering costs nothing. A 500-page statement is where streaming earns its keep.
Once the first bytes are written, the status line and headers are gone; an exception after that point cannot become a 500. Buffering gives you the 500 and a Content-Length and costs heap proportional to the file. For an invoice of about 20 KB that cost is nothing; for a 500-page statement it is the reason to stream and to validate your data before the first byte.

From src/main/java/com/ankurm/pdfinvoice/InvoiceController.java:

ContentDisposition cd = ("inline".equals(disposition) ? ContentDisposition.inline() : ContentDisposition.attachment())
        .filename("बिल-" + number + ".pdf", StandardCharsets.UTF_8)
        .build();

if ("buffered".equals(mode)) {
    ByteArrayOutputStream buf = new ByteArrayOutputStream(64 * 1024);
    writer.write(invoice, buf);               // an exception here is still a clean 500
    if (failAtEnd) {
        throw new IllegalStateException("simulated failure after rendering");
    }
    byte[] bytes = buf.toByteArray();
    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, cd.toString())
            .contentType(MediaType.APPLICATION_PDF)
            .contentLength(bytes.length)
            .body(out -> out.write(bytes));
}
return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION, cd.toString())
        .contentType(MediaType.APPLICATION_PDF)
        .body(out -> {
    ...

What the server answered, from output/02-endpoints.txt (the failure is simulated with ?failAtEnd=true, thrown after the PDF has been written):

--- GET /invoices/openpdf.pdf
HTTP/1.1 200 
Content-Disposition: attachment; filename="__-INV-2026-0042.pdf"; filename*=UTF-8''%E0%A4%AC%E0%A4%BF%E0%A4%B2-INV-2026-0042.pdf
Content-Type: application/pdf
Transfer-Encoding: chunked
--- GET /invoices/openpdf.pdf?mode=buffered
HTTP/1.1 200 
Content-Length: 20039
--- GET /invoices/openpdf.pdf?failAtEnd=true   (streamed: headers are already gone when the failure happens)
HTTP/1.1 200 
Transfer-Encoding: chunked
curl exit=18 bytes_received=20039
--- GET /invoices/openpdf.pdf?failAtEnd=true&mode=buffered   (rendered into memory first)
HTTP/1.1 500 
curl exit=0 bytes_received=116
The streamed failure is the nasty one: HTTP/1.1 200, chunked, and curl exits with code 18 (partial file) after receiving 20039 bytes — here the whole PDF had already arrived, so the browser would probably show a file that looks fine, but with a real mid-render failure it would be a truncated PDF behind a success status. The buffered failure is a plain 500. The Marathi file name goes out as an RFC 6266 filename*=UTF-8'' parameter, which Spring’s ContentDisposition builds for you, next to an ASCII fallback with underscores.
Do not call writer.close() on the servlet stream, and do not render twice. All three writers are told not to close the stream they were given (setCloseStream(false) in OpenPDF and iText, createPDF(out, false) in Flying Saucer), because Spring owns that stream. Also, mode=buffered in this module renders once and sends those bytes; a common mistake is to render once to get the length and once more to stream.

The streaming and cross-linking sibling posts cover related ground: File Upload and Download in Spring Boot 4 for Range and large downloads, and Export Excel and CSV from Spring Boot, which is the same streaming-versus-memory argument for spreadsheets. Marathi and Hindi text in Spring itself is covered in Internationalization (i18n) in Spring Boot 4.

How big and how fast

Bench renders N invoices per engine in one JVM after a warm-up pass and prints the first (cold) invoice separately. This is a shared 2-vCPU sandbox, one run per line, other jobs were running on the same machine, and the font is re-read for every invoice: take the ratios between rows, not the milliseconds.
# n=500 invoices per engine after 100 warm-up, one JVM, engines run one after another
# OpenJDK 64-Bit Server VM 25.0.5 | 2 vCPU
engine                       first_ms    mean_ms     p95_ms    total_s  pdf_bytes
openpdf                         377.0       7.93      18.89       3.96      20034
itext                           330.9      15.88      36.38       7.94      23338
flyingsaucer                   1163.4      28.05      57.66      14.02      20008
openpdf-jdk-actualtext          423.5      42.89     107.47      21.45      22281
flyingsaucer-shaped             107.5      49.99      93.45      24.99      21537

From output/06-bench.txt. Read it as an ordering, not as milliseconds: OpenPDF is the cheapest per invoice (20,034 bytes, 7.9 ms mean in this run), iText next (23,338 bytes, 15.9 ms), Flying Saucer after that (20,008 bytes, 28.1 ms) because it parses XML, resolves CSS and lays out boxes for every invoice, and the two shaped variants are the slowest (22,281 bytes and 42.9 ms for OpenPDF with the JDK shaper and ActualText, 21,537 bytes and 50.0 ms for shaped Flying Saucer). The benchmark was run more than once while other jobs shared the machine and the ratios between rows moved, and only this run is committed, so the ordering is the finding. The first-invoice column is dominated by class loading and by which engine ran first in that JVM, so it is not a fair cold-start comparison. File sizes are 20 to 23 KB for every engine.

Shared state hid a bug in my own benchmark. My first subset measurement said the shaped OpenPDF engine always embeds the whole font. It did not: OpenPDF caches BaseFont instances by name for the whole JVM, and an earlier engine had switched subsetting off on the shared instance, so the shaped engine inherited it. The writer now sets the flag on every render. If you toggle setSubset(false) anywhere, you toggle it for every writer in the process that uses the same cached font.
Going deeper: what I did not measure

No heap or allocation figures were taken, only wall-clock time on one shared 2-vCPU machine, with no concurrent requests. All engines read the same two font files on every invoice, which a real service would cache; with caching the gap between OpenPDF and the others would be different. Large documents (hundreds of pages) were not tested, so the claim that streaming “pays off” for statements is a design argument, not a measurement here.

Testing a PDF: assert on the text, then do not trust it

The cheap, reliable test is to read the PDF back with Apache PDFBox and assert on the extracted text: the invoice number, the line items, the totals. It catches a missing font, a wrong total and a dropped section. PdfContentTest does this for every engine; it also asserts that each font is embedded and carries a subset tag, and that the file stays under 60 KB.

From src/test/java/com/ankurm/pdfinvoice/PdfContentTest.java:

void thePdfCarriesTheInvoiceNumbersAndTheCorrectTotals(String engine) throws Exception {
    byte[] pdf = render(engine);
    assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-");
    String t = text(pdf);
    assertThat(t).contains("INV-2026-0042", "Spring Boot 4 Handbook", "₹ 3,474.50", "₹ 625.41", "₹ 4,099.91");
    try (PDDocument doc = Loader.loadPDF(pdf)) {
        assertThat(doc.getNumberOfPages()).isEqualTo(1);
    }
    log(engine, pdf.length, t);
}
A passing text-extraction test says nothing about shaping. The unshaped engines draw broken conjuncts and extract perfectly, because they write one glyph per code point and the PDF maps each glyph straight back. The shaped engine draws correctly and extracts wrongly unless ActualText is on. The test class pins both directions, in unshapedTextExtractsPerfectly and shapedWithoutActualTextDrawsRightButExtractsWrong. For the look of the page there is no substitute for rendering it (pdftoppm -png) and viewing it; GlyphCountTest gives a number that moves when shaping happens, but it is a proxy.

Which one should you pick?

If you’re…UseBecause
building a Latin-script invoice from Java, closed sourceOpenPDFLGPL/MPL; fastest per invoice in this run
needing Marathi or Hindi that renders correctly, from JavaOpenPDF with GlyphLayoutManager and ActualTextthe only route here that shaped correctly with a sound text layer; slower
with a designer-owned HTML/CSS templateFlying SaucerCSS layout; Devanagari needs the global LayoutProcessor workaround
able to open-source your service under AGPL, or buy a licenceiText Corethe nicest API; shaping needs the proprietary add-on (not run)
Should you even generate the PDF yourself? If the invoice has to be legally archivable (PDF/A), signed, or e-invoicing compliant, none of this post is enough: that is a conformance problem and a commercial-licence conversation, and a hosted or desktop document service may be cheaper than the engineering. If the customer only needs the numbers, send an HTML email or CSV. And if Marathi or Hindi text matters to your customers, view your output before every release: the failure is silent, and the code that produces it passes every test that only reads text.

What I did not test

  • iText’s pdfCalligraph add-on (proprietary licence, needs a key).
  • Any viewer other than poppler’s renderer: Adobe Acrobat, Chrome’s PDF viewer and mobile viewers may differ in how they treat the shaped glyph streams.
  • PDF/A, PDF/UA, signatures, multi-page invoices, headers and footers.
  • Concurrent rendering and heap use; the benchmark is sequential and in one JVM.
  • OpenHTMLtoPDF, headless Chromium and LibreOffice as alternatives.
  • Other Indic scripts (Tamil, Bengali) and right-to-left scripts.
  • The FOP route beyond the invoice itself; the scratch test that showed vanishing viramas is not in the repository.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.