Add pdf-invoice module: one invoice in OpenPDF 3.0.5, iText Core 9.8.0 and Flying Saucer 10.7.0, Devanagari shaping checked by rendering, streamed vs buffered download, subset sizes and timings

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b6Kg9RWSTJzyz6KP9X
This commit is contained in:
2026-10-30 16:46:25 +00:00
co-authored by Claude Sonnet 5.5
parent 9b1da079c6
commit 3a30194459
54 changed files with 1906 additions and 0 deletions
+1
View File
@@ -9,6 +9,7 @@ files.
| Directory | Article | What it demonstrates |
|---|---|---|
| [`pdf-invoice/`](pdf-invoice) | [Generate PDF Invoices in Spring Boot: OpenPDF vs iText vs Flying Saucer (HTML to PDF)](https://ankurm.com/spring-boot-generate-pdf-invoice-openpdf-itext-flying-saucer/) | one invoice rendered by OpenPDF 3.0.5, iText Core 9.8.0 (AGPL 3 or commercial) and Flying Saucer 10.7.0 from a Thymeleaf template, with embedded Noto Sans Devanagari: none of the three shapes Marathi/Hindi conjuncts by default (checked by rendering to PNG against a libraqm reference), OpenPDF's `GlyphLayoutManager` does but breaks text extraction until `ActualText` is on, OpenPDF's static `LayoutProcessor` is JVM-global and breaks other writers, Apache FOP on the classpath did not help, streamed vs buffered failure (`200` + curl exit 18 vs `500`), font subsetting, indicative per-invoice timings |
| [`excel-export/`](excel-export) | [Export Excel and CSV from Spring Boot: Streaming 1 Million Rows with Apache POI SXSSF](https://ankurm.com/spring-boot-export-excel-csv-streaming-poi-sxssf/) | XSSF vs SXSSF under a fixed 512 MB heap (XSSF out of memory between 50,000 and 100,000 rows and at 1,000,000 rows even with 3 GB), 1,000,000 rows streamed with `StreamingResponseBody` under a 64 MB heap, sheet rolling at 1,048,576 rows, `compressTempFiles` cutting the spill from 475 MB to 56 MB, OpenCSV swallowing write errors, and an SXSSF file LibreOffice will not open |
| [`thymeleaf-htmx/`](thymeleaf-htmx) | [Spring Boot 4 + Thymeleaf + htmx: Server-Side CRUD UI in 2026](https://ankurm.com/spring-boot-thymeleaf-htmx-crud-ui/) | one URL answering as full page or fragment on `HX-Request`, a Thymeleaf layout, 422 validation re-render, pagination, inline edit and delete, 10 MockMvc tests plus a headless-Chromium check that observed htmx 2 ignoring a 204 and (without a config tag) a 422 |
| [`rate-limit/`](rate-limit) | [Rate Limiting Spring Boot APIs with Bucket4j and Redis: Per-User, Per-Key and Global Limits](https://ankurm.com/spring-boot-rate-limiting-bucket4j-redis/) | per-user, per-API-key and global token buckets in a servlet filter on Bucket4j 8.21 (`bucket4j_jdk17-*`), `429` with `Retry-After` and `X-RateLimit-*` headers, two application instances sharing one Redis bucket and 200 concurrent requests letting exactly 20 through, a limit raised in `application.yml` that does not change buckets already stored in Redis until a config version is bumped, a client clock 30 s ahead handing out tokens another instance already spent, and fail-open versus fail-closed when Redis dies |
+55
View File
@@ -0,0 +1,55 @@
# pdf-invoice
Companion code for **[Generate PDF Invoices in Spring Boot: OpenPDF vs iText vs Flying Saucer (HTML to PDF)](https://ankurm.com/spring-boot-generate-pdf-invoice-openpdf-itext-flying-saucer/)**.
Spring Boot 4.1.2, JDK 25, OpenPDF 3.0.5, iText Core 9.8.0 (AGPL 3 or commercial), Flying Saucer 10.7.0, Thymeleaf 3.1.5, Apache PDFBox 3.0.8 (tests only). No database, no Docker.
One invoice, rendered by three libraries, with Marathi and Hindi text in Noto Sans Devanagari (SIL OFL 1.1) embedded:
| Engine name | Code | What it is |
|---|---|---|
| `openpdf` | `OpenPdfInvoiceWriter` | (a) OpenPDF's programmatic API, defaults |
| `itext` | `ITextInvoiceWriter` | (b) iText Core 9.8.0, programmatic API, defaults |
| `flyingsaucer` | `FlyingSaucerInvoiceWriter`, `templates/invoice.html` | (c) Thymeleaf template to XHTML to Flying Saucer, defaults |
| `openpdf-jdk`, `openpdf-jdk-actualtext` | `OpenPdfInvoiceWriter` with `Shaping.JDK` | OpenPDF's `GlyphLayoutManager`: text laid out by the JDK's own shaper |
| `openpdf-fop` | `OpenPdfInvoiceWriter` with `Shaping.FOP` | OpenPDF's optional Apache FOP glyph engine (only with `mvn -Pfop`) |
| `flyingsaucer-shaped` | `FlyingSaucerInvoiceWriter` with `shaped=true` | Flying Saucer with OpenPDF's static `LayoutProcessor` switched on |
`InvoiceController` serves `GET /invoices/{engine}.pdf` with `StreamingResponseBody` (default) or a buffered `byte[]` (`?mode=buffered`), a Marathi
`Content-Disposition` file name, `?probe=true` for an invoice that is only a shaping test string, and `?failAtEnd=true` to see what a failure after rendering looks like.
`Bench` (profile `bench`) times N invoices per engine in one JVM.
```bash
export JAVA_HOME=/path/to/jdk-25
mvn -DskipTests package
java -jar target/pdf-invoice-1.0.0.jar # then: curl -o invoice.pdf localhost:8080/invoices/openpdf.pdf
./scripts/run-all.sh # tests + every capture below; rewrites output/ (about 4 minutes)
```
`scripts/run-all.sh` needs poppler (`pdftoppm`, `pdftotext`, `pdffonts`, `pdfinfo`) and Python with Pillow built with libraqm (for the reference line in the comparison image).
Everything in `output/` was produced by that script; the numbers in `06-bench.txt` are indicative (shared 2-vCPU sandbox, one run per line).
## Captured output
| File | What it holds |
|---|---|
| `output/00-versions-and-licences.txt` | licence declarations read from the POMs, sentences from iText's AGPL page, latest GA versions |
| `output/01-text-extraction.txt` | PDFBox text extraction of each engine's invoice (`PdfContentTest`) |
| `output/02-endpoints.txt` | status and headers for every engine, streamed vs buffered, a failure after rendering |
| `output/03-pdffonts-and-producer.txt` | `pdffonts` per engine and the Producer line each library writes |
| `output/04-glyph-counts.txt` | glyph codes written into the page, per engine (`GlyphCountTest`) |
| `output/05-layoutprocessor-trap.txt` | what the static `LayoutProcessor` does to other OpenPDF writers in the same JVM |
| `output/06-bench.txt` | time per invoice, PDF size, subset vs whole font |
| `output/png/shaping-comparison.png` | the probe string drawn by each engine under a Pillow/libraqm reference line |
| `output/png/invoice-*.png` | the first 800 pixel rows of each engine's invoice at 110 dpi |
| `output/pdf/*.pdf` | the PDFs themselves (`probe-*.pdf` are the shaping probe invoices) |
## Not covered here
iText's pdfCalligraph add-on (proprietary licence, needs a licence key, so it was not run), PDF/A and PDF/UA conformance, digital signatures, multi-page invoices and page headers/footers,
and Microsoft Word / Adobe Acrobat / mobile viewers (rendering was checked with poppler's `pdftoppm` only).
## Fonts
`src/main/resources/fonts/` holds static Regular and Bold instances of Noto Sans Devanagari, cut from the variable font with `scripts/make_fonts.py`
(source and licence in the script's header and in `OFL.txt`).
@@ -0,0 +1,20 @@
# licences as declared in the POMs Maven downloaded (~/.m2) and in the pdfCalligraph POM on iText's own repository, fetched 2026-10-30
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;
# sentences from https://itextpdf.com/how-buy/agpl-license (verbatim, page text with tags stripped)
- With the onset of e-invoicing legislation coming into effect across Europe, there is renewed interest in the ZUGFeRD/Factur-X hybrid invoice standard. This article examines how the standard has evolved, and how you can use the dual-licensed iText Core library to easily create ZUGFeRD-compatible files to comply with EN 16931 requirements.
- iText 5 and all subsequent versions of iText are dual-licensed, and available under open source (AGPLv3) or commercial license agreements. Since iText is
- 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.
- It’s a legal violation to use iText Core/Community and our open source add-ons in a non-AGPL environment.
- 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.
# versions: latest GA per maven-metadata.xml
com/github/librepdf/openpdf latest 3.0.5
com/itextpdf/kernel latest 9.8.0
org/xhtmlrenderer/flying-saucer-pdf latest 10.7.0
org/xhtmlrenderer/flying-saucer-pdf-openpdf latest 9.4.0
org/apache/pdfbox/pdfbox latest 3.0.8
+125
View File
@@ -0,0 +1,125 @@
# PDFBox 3.0.8 text extraction of each engine's invoice, written by PdfContentTest
=== flyingsaucer (20012 bytes) ===
Tax Invoice / कर चलन
Invoice INV-2026-0042 | 2026-10-30
From / विक्रेता Bill to / ग्राहक
Ankur Book House, Mumbai श्रीमती क्षमा कुलकर्णी
12 Dadar Market Road, Mumbai 400014 पुणे, महाराष्ट्र 411004
GSTIN 27AAAAA0000A1Z5 GSTIN 27BBBBB1111B1Z6
Item / तपशील Qty Rate Amount / रक्कम
ज्ञानेश्वरी – संपूर्ण ग्रंथ (मराठी) 2 ₹ 450.00 ₹ 900.00
प्रोग्रामिंग की कला (हिन्दी) 1 ₹ 899.00 ₹ 899.00
विद्वान कर्मचारी मार्गदर्शिका 3 ₹ 125.50 ₹ 376.50
Spring Boot 4 Handbook 1 ₹ 1,299.00 ₹ 1,299.00
Subtotal / उपबेरीज ₹ 3,474.50
CGST 9% + SGST 9% / कर ₹ 625.41
Total / एकूण ₹ 4,099.91
कृपया धन्यवाद — द्वारा: अंकुर बुक हाऊस. Payment due in 15 days.
=== flyingsaucer-shaped (21537 bytes) ===
Tax Invoice / कर चलन
Invoice INV-2026-0042 | 2026-10-30
From / ववकेता Bill to / गाहक
Ankur Book House, Mumbai शीमती कमा कुलकणर
12 Dadar Market Road, Mumbai 400014 पणु,े महाराष् 411004
GSTIN 27AAAAA0000A1Z5 GSTIN 27BBBBB1111B1Z6
Item / तपशील Qty Rate Amount / रककम
जानशेरी – सपंणू र गथं (मराठी) 2 ₹ 450.00 ₹ 900.00
पोगाममरग की कला (हहनदी) 1 ₹ 899.00 ₹ 899.00
ववदान कमचर ारी मागदर रशरका 3 ₹ 125.50 ₹ 376.50
Spring Boot 4 Handbook 1 ₹ 1,299.00 ₹ 1,299.00
Subtotal / उपबरेीज ₹ 3,474.50
CGST 9% + SGST 9% / कर ₹ 625.41
Total / एकूण ₹ 4,099.91
कृपया धनयवाद — दारा: अकुंर बकु हाऊस. Payment due in 15 days.
=== itext (23344 bytes) ===
Tax Invoice / कर चलन
Invoice INV-2026-0042 | 2026-10-30
From / विक्रेता Bill to / ग्राहक
Ankur Book House, Mumbai श्रीमती क्षमा कुलकर्णी
12 Dadar Market Road, Mumbai 400014 पुणे, महाराष्ट्र 411004
GSTIN 27AAAAA0000A1Z5 GSTIN 27BBBBB1111B1Z6
Item / तपशील Qty Rate Amount / रक्कम
ज्ञानेश्वरी – संपूर्ण ग्रंथ (मराठी) 2 ₹ 450.00 ₹ 900.00
प्रोग्रामिंग की कला (हिन्दी) 1 ₹ 899.00 ₹ 899.00
विद्वान कर्मचारी मार्गदर्शिका 3 ₹ 125.50 ₹ 376.50
Spring Boot 4 Handbook 1 ₹ 1,299.00 ₹ 1,299.00
Subtotal / उपबेरीज ₹ 3,474.50
CGST 9% + SGST 9% / कर ₹ 625.41
Total / एकूण ₹ 4,099.91
कृपया धन्यवाद — द्वारा: अंकुर बुक हाऊस. Payment due in 15 days.
=== openpdf (20039 bytes) ===
Tax Invoice / कर चलन
Invoice INV-2026-0042 | 2026-10-30
From / विक्रेता Bill to / ग्राहक
Ankur Book House, Mumbai श्रीमती क्षमा कुलकर्णी
12 Dadar Market Road, Mumbai 400014 पुणे, महाराष्ट्र 411004
GSTIN 27AAAAA0000A1Z5 GSTIN 27BBBBB1111B1Z6
Item / तपशील Qty Rate Amount / रक्कम
ज्ञानेश्वरी – संपूर्ण ग्रंथ (मराठी) 2 ₹ 450.00 ₹ 900.00
प्रोग्रामिंग की कला (हिन्दी) 1 ₹ 899.00 ₹ 899.00
विद्वान कर्मचारी मार्गदर्शिका 3 ₹ 125.50 ₹ 376.50
Spring Boot 4 Handbook 1 ₹ 1,299.00 ₹ 1,299.00
Subtotal / उपबेरीज ₹ 3,474.50
CGST 9% + SGST 9% / कर ₹ 625.41
Total / एकूण ₹ 4,099.91
कृपया धन्यवाद — द्वारा: अंकुर बुक हाऊस. Payment due in 15 days.
=== openpdf-jdk (21553 bytes) ===
Tax Invoice / कर चलन
Invoice INV-2026-0042 | 2026-10-30
From / ववकेता Bill to / गाहक
Ankur Book House, Mumbai शीमती कमा कुलकणर
12 Dadar Market Road, Mumbai 400014 पुणे, महाराष् 411004
GSTIN 27AAAAA0000A1Z5 GSTIN 27BBBBB1111B1Z6
Item / तपशील Qty Rate Amount / रककम
जानशेरी – संपूणर गंथ (मराठी) 2 ₹ 450.00 ₹ 900.00
पोगाममरग की कला (हहनदी) 1 ₹ 899.00 ₹ 899.00
ववदान कमरचारी मागदर रशरका 3 ₹ 125.50 ₹ 376.50
Spring Boot 4 Handbook 1 ₹ 1,299.00 ₹ 1,299.00
Subtotal / उपबरेीज ₹ 3,474.50
CGST 9% + SGST 9% / कर ₹ 625.41
Total / एकूण ₹ 4,099.91
कृपया धनयवाद — दारा: अंकुर बकु हाऊस. Payment due in 15 days.
=== openpdf-jdk-actualtext (22285 bytes) ===
Tax Invoice / कर चलन
Invoice INV-2026-0042 | 2026-10-30
From / विक्रेता Bill to / ग्राहक
Ankur Book House, Mumbai श्रीमती क्षमा कुलकर्णी
12 Dadar Market Road, Mumbai 400014 पुणे, महाराष्ट्र 411004
GSTIN 27AAAAA0000A1Z5 GSTIN 27BBBBB1111B1Z6
Item / तपशील Qty Rate Amount / रक्कम
ज्ञानेश्वरी – संपूर्ण ग्रंथ (मराठी) 2 ₹ 450.00 ₹ 900.00
प्रोग्रामिंग की कला (हिन्दी) 1 ₹ 899.00 ₹ 899.00
विद्वान कर्मचारी मार्गदर्शिका 3 ₹ 125.50 ₹ 376.50
Spring Boot 4 Handbook 1 ₹ 1,299.00 ₹ 1,299.00
Subtotal / उपबेरीज ₹ 3,474.50
CGST 9% + SGST 9% / कर ₹ 625.41
Total / एकूण ₹ 4,099.91
कृपया धन्यवाद — द्वारा: अंकुर बुक हाऊस. Payment due in 15 days.
fonts flyingsaucer [LXEQIC+NotoSansDevanagari-Bold embedded=true, OUOZGZ+NotoSansDevanagari-Regular embedded=true]
fonts flyingsaucer-shaped [GJBPWA+NotoSansDevanagari-Bold embedded=true, FYEFNQ+NotoSansDevanagari-Regular embedded=true]
fonts itext [YCXTVQ+NotoSansDevanagari-Bold embedded=true, PXNQYY+NotoSansDevanagari-Regular embedded=true]
fonts openpdf [FFJNLO+NotoSansDevanagari-Bold embedded=true, CRMHHV+NotoSansDevanagari-Regular embedded=true]
fonts openpdf-jdk-actualtext [GEKVVR+NotoSansDevanagari-Bold embedded=true, PPYLBT+NotoSansDevanagari-Regular embedded=true]
+47
View File
@@ -0,0 +1,47 @@
# HTTP: status and headers per engine (curl -D -), then streaming vs buffered, then a failure after rendering
# openjdk version "25.0.5" 2026-10-20 LTS | 2 vCPU | 8027 MB RAM
--- 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/itext.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/flyingsaucer.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-jdk.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-jdk-actualtext.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/flyingsaucer-shaped.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?disposition=inline
HTTP/1.1 200
Content-Disposition: inline; filename="__-INV-2026-0042.pdf"; filename*=UTF-8''%E0%A4%AC%E0%A4%BF%E0%A4%B2-INV-2026-0042.pdf
--- GET /invoices/nope.pdf
HTTP/1.1 404
--- 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
@@ -0,0 +1,43 @@
# pdffonts per engine (poppler): name, type, embedded, subset
--- openpdf (20039 bytes)
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
--- itext (23344 bytes)
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
BYDHEU+NotoSansDevanagari-Bold CID TrueType Identity-H yes yes yes 6 0
EUBWBF+NotoSansDevanagari-Regular CID TrueType Identity-H yes yes yes 7 0
--- flyingsaucer (20011 bytes)
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
AKIOED+NotoSansDevanagari-Bold CID TrueType Identity-H yes yes yes 2 0
WSTLFR+NotoSansDevanagari-Regular CID TrueType Identity-H yes yes yes 3 0
--- openpdf-jdk (21553 bytes)
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
SJCTQQ+NotoSansDevanagari-Bold CID TrueType Identity-H yes yes yes 1 0
SDAHWP+NotoSansDevanagari-Regular CID TrueType Identity-H yes yes yes 2 0
--- openpdf-jdk-actualtext (22285 bytes)
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
VNADNX+NotoSansDevanagari-Bold CID TrueType Identity-H yes yes yes 1 0
EAQUGS+NotoSansDevanagari-Regular CID TrueType Identity-H yes yes yes 2 0
--- flyingsaucer-shaped (21536 bytes)
name type encoding emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
RTHNRU+NotoSansDevanagari-Bold CID TrueType Identity-H yes yes yes 2 0
STZJGA+NotoSansDevanagari-Regular CID TrueType Identity-H yes yes yes 3 0
# Producer/Creator lines the libraries write into the PDF (pdfinfo)
--- openpdf
Title: Invoice INV-2026-0042
Producer: OpenPDF 3.0.5
--- itext
Title: Invoice INV-2026-0042
Producer: iText® Core 9.8.0 (AGPL version) ©2000-2026 Apryse Group NV
--- flyingsaucer
Title: Invoice INV-2026-0042
Creator: Flying Saucer 10.7.0 with OpenPDF 3.0.5
Producer: Flying Saucer 10.7.0 with OpenPDF 3.0.5
+9
View File
@@ -0,0 +1,9 @@
# glyph codes written into page 1 content streams for the probe invoice (Identity-H, 2 bytes each)
# code points drawn, spaces excluded: 364
# the engines also draw the spaces as glyphs, so read the numbers against each other, not against this one
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
@@ -0,0 +1,4 @@
# LayoutProcessor.enable() in the same JVM, then two other OpenPDF writers, then disable()
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
+16
View File
@@ -0,0 +1,16 @@
# in-process timing, openjdk version "25.0.5" 2026-10-20 LTS | 2 vCPU | 8027 MB RAM
# indicative: shared sandbox, one JVM, one run per line
# 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
# subset=false (whole font embedded), same invoice, one render each
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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+102
View File
@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>pdf-invoice</artifactId>
<version>1.0.0</version>
<name>pdf-invoice</name>
<description>One invoice, three PDF libraries in Spring Boot 4.1: OpenPDF, iText Core (AGPL) and Flying Saucer
rendering a Thymeleaf template, with Devanagari text and a check of which one shapes it correctly.</description>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
<openpdf.version>3.0.5</openpdf.version>
<itext.version>9.8.0</itext.version>
<flying-saucer.version>10.7.0</flying-saucer.version>
<pdfbox.version>3.0.8</pdfbox.version>
<fop.version>2.11</fop.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- (a) OpenPDF: LGPL 2.1 / MPL 2.0 -->
<dependency>
<groupId>com.github.librepdf</groupId>
<artifactId>openpdf</artifactId>
<version>${openpdf.version}</version>
</dependency>
<!-- (b) iText Core: AGPL 3 or a commercial licence. Read the post before you ship this one. -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>kernel</artifactId>
<version>${itext.version}</version>
</dependency>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>layout</artifactId>
<version>${itext.version}</version>
</dependency>
<!-- (c) Flying Saucer: LGPL, renders through OpenPDF -->
<dependency>
<groupId>org.xhtmlrenderer</groupId>
<artifactId>flying-saucer-pdf</artifactId>
<version>${flying-saucer.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>${pdfbox.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<profiles>
<!-- mvn -Pfop ...: puts Apache FOP on the classpath, which switches on OpenPDF's optional GSUB engine -->
<profile>
<id>fop</id>
<dependencies>
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>fop</artifactId>
<version>${fop.version}</version>
</dependency>
</dependencies>
</profile>
</profiles>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Crop the item table out of each probe PDF and stack the crops under a Pillow/libraqm reference line.
Usage: compare.py <pdf-dir> <png-dir> <font.ttf>
The reference is the same probe text drawn by Pillow's RAQM layout engine (libraqm -> HarfBuzz) with the same
Noto Sans Devanagari file: an independent shaper, there to show what "correct" looks like.
"""
import re, subprocess, sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
pdfdir, pngdir, fontfile = Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3]
PROBE = "क्षत्रिय ज्ञान श्री द्वार कर्म कि प्रिय"
DPI = 200
ENGINES = [
("openpdf", "OpenPDF 3.0.5, defaults"),
("itext", "iText Core 9.8.0, defaults"),
("flyingsaucer", "Flying Saucer 10.7.0, defaults"),
("openpdf-fop", "OpenPDF + Apache FOP 2.11 on the classpath"),
("openpdf-jdk-actualtext", "OpenPDF GlyphLayoutManager (JDK shaper)"),
("flyingsaucer-shaped", "Flying Saucer + OpenPDF LayoutProcessor"),
]
def box(pdf, word):
out = subprocess.run(["pdftotext", "-bbox", str(pdf), "-"], capture_output=True, text=True).stdout
hits = re.findall(r'<word xMin="([\d.]+)" yMin="([\d.]+)" xMax="([\d.]+)" yMax="([\d.]+)">%s[^<]*</word>' % re.escape(word), out)
return [tuple(map(float, h)) for h in hits]
font_big = ImageFont.truetype(fontfile, 40, layout_engine=ImageFont.Layout.RAQM)
font_lbl = ImageFont.truetype(fontfile, 22, layout_engine=ImageFont.Layout.RAQM)
rows = []
ref = Image.new("RGB", (1500, 120), "white")
d = ImageDraw.Draw(ref)
d.text((10, 6), "reference: Pillow + libraqm (HarfBuzz), same font", font=font_lbl, fill="#555")
d.text((10, 44), PROBE, font=font_big, fill="black")
rows.append(ref)
for engine, label in ENGINES:
pdf = pdfdir / f"probe-{engine}.pdf"
if not pdf.exists():
print("skip", engine, "(no probe pdf)")
continue
item = box(pdf, "Item")[0]
total = box(pdf, "Total")[-1]
y0 = int((item[1] - 8) * DPI / 72)
y1 = int((total[3] + 62) * DPI / 72)
subprocess.run(["pdftoppm", "-r", str(DPI), "-png", "-singlefile", "-x", str(int(36 * DPI / 72)), "-y", str(y0),
"-W", "1500", "-H", str(y1 - y0), str(pdf), str(pngdir / f"crop-{engine}")], check=True)
img = Image.open(pngdir / f"crop-{engine}.png").convert("RGB")
band = Image.new("RGB", (1500, 34), "#f4f5f7")
ImageDraw.Draw(band).text((10, 4), label, font=font_lbl, fill="#222")
rows.append(band)
rows.append(img)
(pngdir / f"crop-{engine}.png").unlink()
W = 1500
H = sum(r.height for r in rows)
out = Image.new("RGB", (W, H), "white")
y = 0
for r in rows:
out.paste(r, (0, y))
y += r.height
out = out.convert("P", palette=Image.ADAPTIVE, colors=32)
out.save(pngdir / "shaping-comparison.png", optimize=True)
print("wrote", pngdir / "shaping-comparison.png", out.size)
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Licence facts, read from the sources at run time: Maven POMs and the iText licence page.
set -u
echo "# licences as declared in the POMs Maven downloaded (~/.m2) and in the pdfCalligraph POM on iText's own repository, fetched $(date -u +%F)"
M2="${HOME}/.m2/repository"
lic() { grep -A1 '<license>' "$1" | grep '<name>' | sed 's/^ *<name>//; s#</name>##' | tr '\n' ';'; }
echo "openpdf 3.0.5 (declared in openpdf-parent): $(lic "$M2/com/github/librepdf/openpdf-parent/3.0.5/openpdf-parent-3.0.5.pom")"
echo "itext kernel/layout 9.8.0 (root pom): $(lic "$M2/com/itextpdf/root/9.8.0/root-9.8.0.pom")"
echo "flying-saucer-pdf 10.7.0 (flying-saucer-parent): $(lic "$M2/org/xhtmlrenderer/flying-saucer-parent/10.7.0/flying-saucer-parent-10.7.0.pom")"
pom() { curl -s --retry 3 "$1" | grep -A2 '<license>' | grep -E '<name>' | sed 's/^ *<name>//; s#</name>##' | tr '\n' ';'; }
echo "typography (pdfCalligraph) 5.1.1: $(pom https://repo.itextsupport.com/releases/com/itextpdf/typography/5.1.1/typography-5.1.1.pom | tr '\n' ' ')"
echo
echo "# sentences from https://itextpdf.com/how-buy/agpl-license (verbatim, page text with tags stripped)"
curl -sL https://itextpdf.com/how-buy/agpl-license | python3 -c '
import re, sys, html
t = sys.stdin.read()
t = re.sub(r"<script.*?</script>|<style.*?</style>", "", t, flags=re.S)
t = html.unescape(re.sub(r"<[^>]+>", "\n", t))
keys = ("dual-licensed", "AGPLv3 license. This license applies", "You may not deploy it on a network", "Producer", "producer line",
"violation to use", "release you from the AGPLv3")
seen = set()
for line in (l.strip() for l in t.split("\n")):
if any(k in line for k in keys) and line not in seen and len(line) > 40:
seen.add(line); print("- " + line)
'
echo
echo "# versions: latest GA per maven-metadata.xml"
for a in com/github/librepdf/openpdf com/itextpdf/kernel org/xhtmlrenderer/flying-saucer-pdf org/xhtmlrenderer/flying-saucer-pdf-openpdf org/apache/pdfbox/pdfbox; do
v=""
for _ in 1 2 3 4 5 6; do # Maven Central answers 429 now and then
v="$(curl -s https://repo1.maven.org/maven2/$a/maven-metadata.xml | tr '<' '\n' | grep -E '^(release|latest)>' | head -1 | sed 's/>/ /')"
[ -n "$v" ] && break; sleep 15
done
printf '%-45s %s\n' "$a" "${v:-(rate limited, not read)}"
done
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Cut static Regular and Bold instances out of the Noto Sans Devanagari variable font.
Source (SIL OFL 1.1, copyright The Noto Project Authors; OFL.txt next to the output files):
https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/notosansdevanagari/NotoSansDevanagari%5Bwdth%2Cwght%5D.ttf
(a jsDelivr mirror of github.com/google/fonts; raw.githubusercontent.com was blocked in the sandbox this was built in)
Why static instances: OpenPDF and iText read a variable font's default master only, and Flying Saucer
picks weights by file. Two small static files make "regular" and "bold" mean the same thing in all three.
Needs: pip install fonttools
"""
import sys
from fontTools.ttLib import TTFont
from fontTools.varLib import instancer
src, outdir = sys.argv[1], sys.argv[2]
for name, wght in (("Regular", 400), ("Bold", 700)):
font = TTFont(src)
inst = instancer.instantiateVariableFont(font, {"wght": wght, "wdth": 100}, updateFontNames=True)
inst.save(f"{outdir}/NotoSansDevanagari-{name}.ttf")
print("wrote", name)
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Regenerates everything under output/. Needs JDK 25, Maven, curl, poppler (pdftoppm, pdftotext, pdffonts),
# python3 with Pillow built with libraqm. Network is needed once for Maven and for 00-versions-and-licences.txt.
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p output/pdf output/png
JAR=target/pdf-invoice-1.0.0.jar
HOST="$(java -version 2>&1 | grep -v JAVA_TOOL | head -1) | $(nproc) vCPU | $(awk '/MemTotal/ {printf "%d MB RAM", $2/1024}' /proc/meminfo)"
scripts/licences.sh > output/00-versions-and-licences.txt || echo "licence check skipped (offline?)"
mvn -q -B test # writes output/01, 04, 05
mvn -q -B -DskipTests package
serve() { # serve <port> : start the packaged jar, wait until it answers
setsid nohup java -jar "$JAR" --server.port="$1" > "target/app-$1.log" 2>&1 < /dev/null &
echo $! > "target/app-$1.pid"
for _ in $(seq 1 60); do curl -s -o /dev/null "localhost:$1/invoices/openpdf.pdf" && return 0; sleep 1; done
echo "app did not start" >&2; return 1
}
stop() { kill "$(cat "target/app-$1.pid")" 2>/dev/null || true; }
P=$((20000 + RANDOM % 10000))
serve "$P"
ENGINES="openpdf itext flyingsaucer openpdf-jdk openpdf-jdk-actualtext flyingsaucer-shaped"
for e in $ENGINES; do
curl -s -o "output/pdf/$e.pdf" "localhost:$P/invoices/$e.pdf"
curl -s -o "output/pdf/probe-$e.pdf" "localhost:$P/invoices/$e.pdf?probe=true"
done
{
echo "# HTTP: status and headers per engine (curl -D -), then streaming vs buffered, then a failure after rendering"
echo "# $HOST"
for e in $ENGINES; do
echo "--- GET /invoices/$e.pdf"
curl -s -D - -o /dev/null "localhost:$P/invoices/$e.pdf" | tr -d '\r' | grep -i -E '^HTTP|^content-type|^content-disposition|^content-length|^transfer-encoding'
done
echo "--- GET /invoices/openpdf.pdf?mode=buffered"
curl -s -D - -o /dev/null "localhost:$P/invoices/openpdf.pdf?mode=buffered" | tr -d '\r' | grep -i -E '^HTTP|^content-length|^transfer-encoding'
echo "--- GET /invoices/openpdf.pdf?disposition=inline"
curl -s -D - -o /dev/null "localhost:$P/invoices/openpdf.pdf?disposition=inline" | tr -d '\r' | grep -i -E '^HTTP|^content-disposition'
echo "--- GET /invoices/nope.pdf"
curl -s -D - -o /dev/null "localhost:$P/invoices/nope.pdf" | tr -d '\r' | grep -i -E '^HTTP'
echo "--- GET /invoices/openpdf.pdf?failAtEnd=true (streamed: headers are already gone when the failure happens)"
curl -s -D - -o target/fail-stream.pdf -w "curl exit=%{exitcode} bytes_received=%{size_download}\n" "localhost:$P/invoices/openpdf.pdf?failAtEnd=true" 2>&1 | tr -d '\r' | grep -i -E '^HTTP|^content-length|^transfer-encoding|curl exit' || true
echo "--- GET /invoices/openpdf.pdf?failAtEnd=true&mode=buffered (rendered into memory first)"
curl -s -D - -o target/fail-buffered.bin -w "curl exit=%{exitcode} bytes_received=%{size_download}\n" "localhost:$P/invoices/openpdf.pdf?failAtEnd=true&mode=buffered" 2>&1 | tr -d '\r' | grep -i -E '^HTTP|^content-length|curl exit' || true
} > output/02-endpoints.txt
{
echo "# pdffonts per engine (poppler): name, type, embedded, subset"
for e in $ENGINES; do echo "--- $e ($(stat -c %s "output/pdf/$e.pdf") bytes)"; pdffonts "output/pdf/$e.pdf"; done
echo; echo "# Producer/Creator lines the libraries write into the PDF (pdfinfo)"
for e in openpdf itext flyingsaucer; do echo "--- $e"; pdfinfo "output/pdf/$e.pdf" | grep -E '^(Title|Creator|Producer)'; done
} > output/03-pdffonts-and-producer.txt
stop "$P"
# the FOP variant needs Apache FOP (121 jars with Batik) on the classpath: Maven profile fop
mvn -q -B -Pfop -DskipTests package
P2=$((20000 + RANDOM % 10000))
serve "$P2"
curl -s -o output/pdf/openpdf-fop.pdf "localhost:$P2/invoices/openpdf-fop.pdf"
curl -s -o output/pdf/probe-openpdf-fop.pdf "localhost:$P2/invoices/openpdf-fop.pdf?probe=true"
stop "$P2"
mvn -q -B -DskipTests package # back to the default jar
for e in openpdf itext flyingsaucer openpdf-jdk-actualtext flyingsaucer-shaped openpdf-fop; do
pdftoppm -r 110 -png -singlefile -H 800 "output/pdf/$e.pdf" "output/png/invoice-$e"
done
python3 scripts/compare.py output/pdf output/png src/main/resources/fonts/NotoSansDevanagari-Regular.ttf
{
echo "# in-process timing, $HOST"
echo "# indicative: shared sandbox, one JVM, one run per line"
java -jar "$JAR" --spring.profiles.active=bench --spring.main.web-application-type=none \
--bench.n="${BENCH_N:-500}" --bench.engines=openpdf,itext,flyingsaucer,openpdf-jdk-actualtext,flyingsaucer-shaped 2>/dev/null | grep -E '^#|^[a-z]'
} > output/06-bench.txt
echo done
@@ -0,0 +1,85 @@
package com.ankurm.pdfinvoice;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
/**
* Indicative timing only: {@code java -jar target/pdf-invoice-1.0.0.jar --spring.profiles.active=bench
* --spring.main.web-application-type=none --bench.n=500 --bench.engines=openpdf,itext,...}.
* One JVM, one engine after another, a warm-up pass first, one run per line, shared 2-vCPU sandbox.
*/
@Component
@Profile("bench")
public class Bench implements ApplicationRunner {
private final List<InvoiceWriter> writers;
@Value("${bench.n:500}")
private int n;
@Value("${bench.warmup:100}")
private int warmup;
@Value("${bench.engines:openpdf,itext,flyingsaucer}")
private String engines;
public Bench(List<InvoiceWriter> writers) {
this.writers = writers;
}
private static final class CountingSink extends OutputStream {
long bytes;
@Override public void write(int b) { bytes++; }
@Override public void write(byte[] b, int off, int len) { bytes += len; }
}
@Override
public void run(ApplicationArguments args) throws Exception {
System.out.printf("# n=%d invoices per engine after %d warm-up, one JVM, engines run one after another%n", n, warmup);
System.out.println("# " + System.getProperty("java.vm.name") + " " + System.getProperty("java.version")
+ " | " + Runtime.getRuntime().availableProcessors() + " vCPU");
System.out.printf("%-26s %10s %10s %10s %10s %10s%n", "engine", "first_ms", "mean_ms", "p95_ms", "total_s", "pdf_bytes");
for (String name : Arrays.asList(engines.split(","))) {
InvoiceWriter w = writers.stream().filter(x -> x.engine().equals(name)).findFirst().orElseThrow();
// cold: the very first invoice this JVM renders with this engine (class loading, font parsing, template parse)
long t0 = System.nanoTime();
CountingSink first = new CountingSink();
w.write(SampleInvoices.sample("INV-0"), first);
double firstMs = (System.nanoTime() - t0) / 1e6;
for (int i = 0; i < warmup; i++) {
w.write(SampleInvoices.sample("INV-W" + i), new CountingSink());
}
List<Double> each = new ArrayList<>(n);
long size = 0;
long start = System.nanoTime();
for (int i = 0; i < n; i++) {
CountingSink sink = new CountingSink();
long s = System.nanoTime();
w.write(SampleInvoices.sample(String.format("INV-%05d", i)), sink);
each.add((System.nanoTime() - s) / 1e6);
size = sink.bytes;
}
double total = (System.nanoTime() - start) / 1e9;
each.sort(Double::compare);
System.out.printf("%-26s %10.1f %10.2f %10.2f %10.2f %10d%n", name, firstMs, total * 1000 / n,
each.get((int) (n * 0.95)), total, size);
}
System.out.println("# subset=false (whole font embedded), same invoice, one render each");
for (String name : Arrays.asList(engines.split(","))) {
InvoiceWriter w = writers.stream().filter(x -> x.engine().equals(name)).findFirst().orElseThrow();
CountingSink on = new CountingSink();
w.write(SampleInvoices.sample("INV-S"), on);
w.setSubset(false);
CountingSink off = new CountingSink();
w.write(SampleInvoices.sample("INV-S"), off);
w.setSubset(true);
System.out.printf("%-26s subset=%d bytes, whole font=%d bytes%n", name, on.bytes, off.bytes);
}
System.exit(0);
}
}
@@ -0,0 +1,120 @@
package com.ankurm.pdfinvoice;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import org.openpdf.text.pdf.BaseFont;
import org.openpdf.text.pdf.LayoutProcessor;
import org.xhtmlrenderer.pdf.FontDescription;
import org.xhtmlrenderer.pdf.FontFamily;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.xhtmlrenderer.pdf.ITextRenderer;
/**
* (c) Flying Saucer 10.7.0: a Thymeleaf template is rendered to well-formed XHTML, then laid out by
* Flying Saucer's CSS engine and written through OpenPDF. Fonts must be handed over as a file path,
* so the two Noto instances are copied from the classpath to a temp directory once.
*/
public class FlyingSaucerInvoiceWriter implements InvoiceWriter {
private final TemplateEngine templates;
private final Path fontDir;
private final String engine;
private final boolean shaped;
/**
* @param shaped when true, the two embedded fonts are registered with OpenPDF's static
* {@code LayoutProcessor}, so every {@code showText} goes through java.awt text layout
* (HarfBuzz inside the JDK). Flying Saucer itself still measures text without shaping.
*/
public FlyingSaucerInvoiceWriter(TemplateEngine templates, String engine, boolean shaped) throws Exception {
this.templates = templates;
this.engine = engine;
this.shaped = shaped;
this.fontDir = Files.createTempDirectory("pdf-invoice-fonts");
Files.write(fontDir.resolve("NotoSansDevanagari-Regular.ttf"), FontBytes.load(FontBytes.REGULAR));
Files.write(fontDir.resolve("NotoSansDevanagari-Bold.ttf"), FontBytes.load(FontBytes.BOLD));
}
private volatile boolean subset = true;
@Override
public void setSubset(boolean subset) {
this.subset = subset;
}
@Override
public String engine() {
return engine;
}
/** Step one on its own, so a test can look at the HTML Flying Saucer is given. */
public String html(Invoice inv) {
Context ctx = new Context(Locale.of("en", "IN"));
ctx.setVariable("inv", inv);
ctx.setVariable("money", new Money.Fmt());
return templates.process("invoice", ctx);
}
/**
* {@code LayoutProcessor} is a JVM-wide static switch and deprecated: while it is enabled, OpenPDF's own
* {@code GlyphLayoutManager} refuses to run ("Don't use the deprecated LayoutProcessor!") and plain
* font creation fails in other threads. So the shaped variant takes a global lock and switches it off again.
*/
private static final Object LAYOUT_PROCESSOR_LOCK = new Object();
@Override
public void write(Invoice inv, OutputStream out) throws Exception {
if (shaped) {
synchronized (LAYOUT_PROCESSOR_LOCK) {
LayoutProcessor.enable();
try {
render(inv, out);
} finally {
LayoutProcessor.disable();
}
}
} else {
render(inv, out);
}
}
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();
}
public void cleanUp() throws Exception {
try (var files = Files.list(fontDir)) {
for (Path p : files.toList()) {
Files.deleteIfExists(p);
}
}
Files.deleteIfExists(fontDir);
}
}
@@ -0,0 +1,39 @@
package com.ankurm.pdfinvoice;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
/** Loads the OFL-licensed Noto Sans Devanagari instances from the classpath. */
public final class FontBytes {
public static final String REGULAR = "fonts/NotoSansDevanagari-Regular.ttf";
public static final String BOLD = "fonts/NotoSansDevanagari-Bold.ttf";
private FontBytes() {}
/** Copies a classpath font to a temp file (deleted on JVM exit) for libraries that insist on a file path. */
public static java.nio.file.Path tempFile(String resource) {
try {
java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("pdf-invoice-font");
java.nio.file.Path f = dir.resolve(java.nio.file.Path.of(resource).getFileName().toString());
java.nio.file.Files.write(f, load(resource));
f.toFile().deleteOnExit();
dir.toFile().deleteOnExit();
return f;
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public static byte[] load(String resource) {
try (InputStream in = FontBytes.class.getClassLoader().getResourceAsStream(resource)) {
if (in == null) {
throw new IllegalStateException("missing font resource " + resource);
}
return in.readAllBytes();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
@@ -0,0 +1,123 @@
package com.ankurm.pdfinvoice;
import java.io.OutputStream;
import java.math.BigDecimal;
import com.itextpdf.io.font.PdfEncodings;
import com.itextpdf.kernel.colors.DeviceRgb;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.font.PdfFontFactory.EmbeddingStrategy;
import com.itextpdf.kernel.geom.PageSize;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.borders.Border;
import com.itextpdf.layout.element.Cell;
import com.itextpdf.layout.element.Paragraph;
import com.itextpdf.layout.element.Table;
import com.itextpdf.layout.properties.TextAlignment;
import com.itextpdf.layout.properties.UnitValue;
import org.springframework.stereotype.Component;
/**
* (b) iText Core 9.8.0. Same layout as the OpenPDF writer. The library is AGPL 3 (or commercial);
* see the post and output/00-itext-licence.txt before putting this class in a closed-source service.
*/
@Component
public class ITextInvoiceWriter implements InvoiceWriter {
private volatile boolean subset = true;
@Override
public void setSubset(boolean subset) {
this.subset = subset;
}
@Override
public String engine() {
return "itext";
}
@Override
public void write(Invoice inv, OutputStream out) throws Exception {
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);
doc.add(new Paragraph("Tax Invoice / कर चलन").setFont(bold).setFontSize(20));
doc.add(new Paragraph("Invoice " + inv.number() + " | " + inv.date()));
doc.add(new Paragraph(" "));
Table parties = new Table(UnitValue.createPercentArray(new float[] {1, 1})).useAllAvailableWidth();
parties.addCell(partyCell("From / विक्रेता", inv.seller(), bold));
parties.addCell(partyCell("Bill to / ग्राहक", inv.buyer(), bold));
doc.add(parties);
doc.add(new Paragraph(" "));
Table items = new Table(UnitValue.createPercentArray(new float[] {6, 1.2f, 2, 2})).useAllAvailableWidth();
header(items, "Item / तपशील", TextAlignment.LEFT, bold);
header(items, "Qty", TextAlignment.RIGHT, bold);
header(items, "Rate", TextAlignment.RIGHT, bold);
header(items, "Amount / रक्कम", TextAlignment.RIGHT, bold);
for (Invoice.Line l : inv.lines()) {
items.addCell(plain(l.description(), TextAlignment.LEFT));
items.addCell(plain(String.valueOf(l.quantity()), TextAlignment.RIGHT));
items.addCell(plain(Money.inr(l.unitPrice()), TextAlignment.RIGHT));
items.addCell(plain(Money.inr(l.amount()), TextAlignment.RIGHT));
}
doc.add(items);
doc.add(new Paragraph(" "));
Table totals = new Table(UnitValue.createPercentArray(new float[] {8, 3})).setWidth(UnitValue.createPercentValue(60));
totals.setHorizontalAlignment(com.itextpdf.layout.properties.HorizontalAlignment.RIGHT);
total(totals, "Subtotal / उपबेरीज", inv.subtotal(), null);
total(totals, "CGST 9% + SGST 9% / कर", inv.gst(), null);
total(totals, "Total / एकूण", inv.total(), bold);
doc.add(totals);
doc.add(new Paragraph(" "));
doc.add(new Paragraph(inv.note()));
doc.close();
}
private static Cell partyCell(String label, Invoice.Party p, PdfFont bold) {
return new Cell().setPadding(6).setBorder(new com.itextpdf.layout.borders.SolidBorder(new DeviceRgb(0xB7, 0xBE, 0xC9), 0.5f))
.add(new Paragraph(label).setFont(bold))
.add(new Paragraph(p.name()))
.add(new Paragraph(p.address()))
.add(new Paragraph("GSTIN " + p.gstin()));
}
private static void header(Table t, String text, TextAlignment a, PdfFont bold) {
t.addHeaderCell(new Cell().add(new Paragraph(text).setFont(bold).setTextAlignment(a))
.setBackgroundColor(new DeviceRgb(0xF4, 0xF5, 0xF7)).setPadding(5));
}
private static Cell plain(String text, TextAlignment a) {
return new Cell().add(new Paragraph(text).setTextAlignment(a)).setPadding(5);
}
private static void total(Table t, String label, BigDecimal v, PdfFont font) {
Paragraph l = new Paragraph(label);
Paragraph r = new Paragraph(Money.inr(v)).setTextAlignment(TextAlignment.RIGHT);
if (font != null) {
l.setFont(font);
r.setFont(font);
}
t.addCell(new Cell().add(l).setBorder(Border.NO_BORDER));
t.addCell(new Cell().add(r).setBorder(Border.NO_BORDER));
}
}
@@ -0,0 +1,37 @@
package com.ankurm.pdfinvoice;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.List;
/**
* The one invoice all three libraries render. Plain records, no framework types, so each writer
* is a function {@code Invoice -> bytes} and the comparison is about the PDF libraries, not about Spring.
*/
public record Invoice(String number, LocalDate date, Party seller, Party buyer, List<Line> lines, String note) {
public record Party(String name, String address, String gstin) {}
/** {@code description} may be Devanagari; that is the point of the exercise. */
public record Line(String description, int quantity, BigDecimal unitPrice) {
public BigDecimal amount() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
}
/** GST at 18 percent, split evenly into CGST and SGST as an intra-state sale would be. */
public static final BigDecimal GST_RATE = new BigDecimal("0.18");
public BigDecimal subtotal() {
return lines.stream().map(Line::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
}
public BigDecimal gst() {
return subtotal().multiply(GST_RATE).setScale(2, RoundingMode.HALF_UP);
}
public BigDecimal total() {
return subtotal().add(gst());
}
}
@@ -0,0 +1,86 @@
package com.ankurm.pdfinvoice;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
/**
* Two ways to hand a generated PDF to a browser.
* <ul>
* <li><b>stream</b> (default): {@link StreamingResponseBody} writes straight to the response. No whole-file
* buffer, but the status line and headers are gone the moment the first bytes leave, so a failure
* half-way is a truncated 200.</li>
* <li><b>buffered</b>: render to a byte[] first. Costs heap, gains a Content-Length and a clean 500 on failure.</li>
* </ul>
*/
@RestController
public class InvoiceController {
private final Map<String, InvoiceWriter> writers;
public InvoiceController(List<InvoiceWriter> all) {
this.writers = all.stream().collect(Collectors.toMap(InvoiceWriter::engine, Function.identity()));
}
@GetMapping("/invoices/{engine}.pdf")
public ResponseEntity<StreamingResponseBody> invoice(@PathVariable String engine,
@RequestParam(defaultValue = "INV-2026-0042") String number,
@RequestParam(defaultValue = "stream") String mode,
@RequestParam(defaultValue = "false") boolean failAtEnd,
@RequestParam(defaultValue = "attachment") String disposition,
@RequestParam(defaultValue = "false") boolean probe) throws Exception {
InvoiceWriter writer = writers.get(engine);
if (writer == null) {
throw new ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "unknown engine " + engine);
}
Invoice invoice = probe ? SampleInvoices.probe(number) : SampleInvoices.sample(number);
// A Marathi file name goes out as RFC 6266 filename* (UTF-8, percent-encoded) next to an ASCII fallback.
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 -> {
try {
writer.write(invoice, out);
} catch (IOException | RuntimeException e) {
throw e;
} catch (Exception e) {
throw new IOException("PDF rendering failed", e);
}
if (failAtEnd) {
throw new IllegalStateException("simulated failure after rendering");
}
});
}
}
@@ -0,0 +1,14 @@
package com.ankurm.pdfinvoice;
import java.io.OutputStream;
/** One implementation per library. Writers must not close the stream they are given. */
public interface InvoiceWriter {
String engine();
void write(Invoice invoice, OutputStream out) throws Exception;
/** Embed only the glyphs used (default) or the whole font file. Exists so the post can measure the difference. */
void setSubset(boolean subset);
}
@@ -0,0 +1,29 @@
package com.ankurm.pdfinvoice;
import java.math.BigDecimal;
import java.text.NumberFormat;
import java.util.Locale;
/**
* Rupee formatting with Indian digit grouping (12,34,567.00), shared by all three writers.
* It is a class of our own because Thymeleaf 3.1 refuses to call methods on {@code java.*} types
* from a template expression ("Calling method 'format' is forbidden for type DecimalFormat").
*/
public final class Money {
private Money() {}
public static String inr(BigDecimal v) {
NumberFormat f = NumberFormat.getNumberInstance(Locale.of("en", "IN"));
f.setMinimumFractionDigits(2);
f.setMaximumFractionDigits(2);
return "₹ " + f.format(v);
}
/** Template-friendly instance wrapper: {@code ${money.inr(x)}}. */
public static final class Fmt {
public String inr(BigDecimal v) {
return Money.inr(v);
}
}
}
@@ -0,0 +1,170 @@
package com.ankurm.pdfinvoice;
import java.awt.Color;
import java.io.ByteArrayInputStream;
import java.io.OutputStream;
import org.openpdf.text.Document;
import org.openpdf.text.Element;
import org.openpdf.text.Font;
import org.openpdf.text.PageSize;
import org.openpdf.text.TextRenderingOptions;
import org.openpdf.text.pdf.GlyphLayoutManager;
import org.openpdf.text.Paragraph;
import org.openpdf.text.Phrase;
import org.openpdf.text.pdf.BaseFont;
import org.openpdf.text.pdf.PdfPCell;
import org.openpdf.text.pdf.PdfPTable;
import org.openpdf.text.pdf.PdfWriter;
/** (a) The programmatic API: OpenPDF 3.0.5, package {@code org.openpdf.text} (it was {@code com.lowagie.text} before 2.x/3.x). */
public class OpenPdfInvoiceWriter implements InvoiceWriter {
/**
* What OpenPDF is asked to do about complex-script shaping.
* <ul>
* <li>NONE: the default. One glyph per code point, in logical order.</li>
* <li>FOP: {@code TextRenderingOptions}; OpenPDF calls Apache FOP's GSUB engine, but only if
* {@code org.apache.xmlgraphics:fop} is on the classpath (optional dependency; Maven profile {@code fop}).</li>
* <li>JDK: OpenPDF's {@code GlyphLayoutManager}; text is laid out by {@code java.awt.Font}, whose shaper is
* the JDK's bundled HarfBuzz, and the resulting glyph ids are written to the PDF.</li>
* <li>JDK_ACTUAL_TEXT: as JDK, plus {@code /ActualText} marked content so copy and search get the original text back.</li>
* </ul>
*/
public enum Shaping { NONE, FOP, JDK, JDK_ACTUAL_TEXT }
private final Shaping shaping;
private final String engine;
private volatile boolean subset = true;
public OpenPdfInvoiceWriter(String engine, Shaping shaping) {
this.engine = engine;
this.shaping = shaping;
}
@Override
public void setSubset(boolean subset) {
this.subset = subset;
}
@Override
public String engine() {
return engine;
}
@Override
public void write(Invoice inv, OutputStream out) throws Exception {
Document doc = new Document(PageSize.A4, 40, 40, 40, 40);
PdfWriter writer = PdfWriter.getInstance(doc, out);
writer.setCloseStream(false);
Font body;
Font strong;
Font title;
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);
// BaseFont instances are cached by name JVM-wide, so this flag is shared with the other OpenPDF writers
body.getBaseFont().setSubset(subset);
strong.getBaseFont().setSubset(subset);
} else {
// 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);
regular.setSubset(subset);
bold.setSubset(subset);
if (shaping == Shaping.FOP) {
doc.setTextRenderingOptions(new TextRenderingOptions(TextRenderingOptions.DOCUMENT_LANGUAGE_DEFAULT, true));
}
}
doc.addTitle("Invoice " + inv.number());
doc.open();
doc.add(new Paragraph("Tax Invoice / कर चलन", title));
doc.add(new Paragraph("Invoice " + inv.number() + " | " + inv.date(), body));
doc.add(new Paragraph(" ", body));
PdfPTable parties = new PdfPTable(2);
parties.setWidthPercentage(100);
parties.addCell(partyCell("From / विक्रेता", inv.seller(), strong, body));
parties.addCell(partyCell("Bill to / ग्राहक", inv.buyer(), strong, body));
doc.add(parties);
doc.add(new Paragraph(" ", body));
PdfPTable items = new PdfPTable(new float[] {6, 1.2f, 2, 2});
items.setWidthPercentage(100);
header(items, "Item / तपशील", Element.ALIGN_LEFT, strong);
header(items, "Qty", Element.ALIGN_RIGHT, strong);
header(items, "Rate", Element.ALIGN_RIGHT, strong);
header(items, "Amount / रक्कम", Element.ALIGN_RIGHT, strong);
for (Invoice.Line l : inv.lines()) {
items.addCell(plain(l.description(), Element.ALIGN_LEFT, body));
items.addCell(plain(String.valueOf(l.quantity()), Element.ALIGN_RIGHT, body));
items.addCell(plain(Money.inr(l.unitPrice()), Element.ALIGN_RIGHT, body));
items.addCell(plain(Money.inr(l.amount()), Element.ALIGN_RIGHT, body));
}
doc.add(items);
doc.add(new Paragraph(" ", body));
PdfPTable totals = new PdfPTable(new float[] {8, 3});
totals.setWidthPercentage(60);
totals.setHorizontalAlignment(Element.ALIGN_RIGHT);
total(totals, "Subtotal / उपबेरीज", inv.subtotal(), body);
total(totals, "CGST 9% + SGST 9% / कर", inv.gst(), body);
total(totals, "Total / एकूण", inv.total(), strong);
doc.add(totals);
doc.add(new Paragraph(" ", body));
doc.add(new Paragraph(inv.note(), body));
doc.close();
}
private static PdfPCell partyCell(String label, Invoice.Party p, Font strong, Font body) {
PdfPCell c = new PdfPCell();
c.setBorderColor(new Color(0xB7, 0xBE, 0xC9));
c.setPadding(6);
c.addElement(new Paragraph(label, strong));
c.addElement(new Paragraph(p.name(), body));
c.addElement(new Paragraph(p.address(), body));
c.addElement(new Paragraph("GSTIN " + p.gstin(), body));
return c;
}
private static void header(PdfPTable t, String text, int align, Font f) {
PdfPCell c = new PdfPCell(new Phrase(text, f));
c.setHorizontalAlignment(align);
c.setBackgroundColor(new Color(0xF4, 0xF5, 0xF7));
c.setPadding(5);
t.addCell(c);
}
private static PdfPCell plain(String text, int align, Font f) {
PdfPCell c = new PdfPCell(new Phrase(text, f));
c.setHorizontalAlignment(align);
c.setPadding(5);
return c;
}
private static void total(PdfPTable t, String label, java.math.BigDecimal v, Font f) {
PdfPCell a = new PdfPCell(new Phrase(label, f));
a.setBorder(0);
t.addCell(a);
PdfPCell b = new PdfPCell(new Phrase(Money.inr(v), f));
b.setBorder(0);
b.setHorizontalAlignment(Element.ALIGN_RIGHT);
t.addCell(b);
}
}
@@ -0,0 +1,11 @@
package com.ankurm.pdfinvoice;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class PdfInvoiceApplication {
public static void main(String[] args) {
SpringApplication.run(PdfInvoiceApplication.class, args);
}
}
@@ -0,0 +1,40 @@
package com.ankurm.pdfinvoice;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/** Deterministic sample data: Latin, Marathi and Hindi text in one invoice. */
public final class SampleInvoices {
private SampleInvoices() {}
/**
* The Devanagari strings are chosen to contain what breaks naive rendering: the conjuncts
* क्ष (ksha), ज्ञ (jna), श्र (shra), द्व (dva), the reph in कर्म, the short-i matra ि that is
* stored after the consonant but drawn before it, and the ra-vattu in प्रोग्रामिंग.
*/
public static Invoice sample(String number) {
return new Invoice(
number,
LocalDate.of(2026, 10, 30),
new Invoice.Party("Ankur Book House, Mumbai", "12 Dadar Market Road, Mumbai 400014", "27AAAAA0000A1Z5"),
new Invoice.Party("श्रीमती क्षमा कुलकर्णी", "पुणे, महाराष्ट्र 411004", "27BBBBB1111B1Z6"),
List.of(
new Invoice.Line("ज्ञानेश्वरी – संपूर्ण ग्रंथ (मराठी)", 2, new BigDecimal("450.00")),
new Invoice.Line("प्रोग्रामिंग की कला (हिन्दी)", 1, new BigDecimal("899.00")),
new Invoice.Line("विद्वान कर्मचारी मार्गदर्शिका", 3, new BigDecimal("125.50")),
new Invoice.Line("Spring Boot 4 Handbook", 1, new BigDecimal("1299.00"))),
"कृपया धन्यवाद — द्वारा: अंकुर बुक हाऊस. Payment due in 15 days.");
}
/** An invoice whose only content is the shaping probe, once in a table cell and once in a plain paragraph. */
public static Invoice probe(String number) {
Invoice base = sample(number);
return new Invoice(number, base.date(), base.seller(), base.buyer(),
List.of(new Invoice.Line(SHAPING_PROBE, 1, new BigDecimal("1.00"))), SHAPING_PROBE);
}
/** A line of text whose correct shaping is easy to check by eye. */
public static final String SHAPING_PROBE = "क्षत्रिय ज्ञान श्री द्वार कर्म कि प्रिय";
}
@@ -0,0 +1,40 @@
package com.ankurm.pdfinvoice;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.thymeleaf.TemplateEngine;
/** The OpenPDF writer comes in three shaping flavours, registered under three engine names. */
@Configuration
public class WriterConfig {
@Bean
OpenPdfInvoiceWriter openPdf() {
return new OpenPdfInvoiceWriter("openpdf", OpenPdfInvoiceWriter.Shaping.NONE);
}
@Bean
OpenPdfInvoiceWriter openPdfJdk() {
return new OpenPdfInvoiceWriter("openpdf-jdk", OpenPdfInvoiceWriter.Shaping.JDK);
}
@Bean
OpenPdfInvoiceWriter openPdfJdkActualText() {
return new OpenPdfInvoiceWriter("openpdf-jdk-actualtext", OpenPdfInvoiceWriter.Shaping.JDK_ACTUAL_TEXT);
}
@Bean
OpenPdfInvoiceWriter openPdfFop() {
return new OpenPdfInvoiceWriter("openpdf-fop", OpenPdfInvoiceWriter.Shaping.FOP);
}
@Bean(destroyMethod = "cleanUp")
FlyingSaucerInvoiceWriter flyingSaucer(TemplateEngine templates) throws Exception {
return new FlyingSaucerInvoiceWriter(templates, "flyingsaucer", false);
}
@Bean(destroyMethod = "cleanUp")
FlyingSaucerInvoiceWriter flyingSaucerShaped(TemplateEngine templates) throws Exception {
return new FlyingSaucerInvoiceWriter(templates, "flyingsaucer-shaped", true);
}
}
@@ -0,0 +1,93 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/devanagari)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<title th:text="'Invoice ' + ${inv.number}">Invoice</title>
<style type="text/css">
@page { size: A4; margin: 40pt; }
body { font-family: 'Noto Sans Devanagari'; font-size: 10pt; color: #000; }
h1 { font-size: 20pt; font-weight: bold; margin: 0 0 4pt 0; }
table { width: 100%; border-collapse: collapse; }
th { background-color: #f4f5f7; font-weight: bold; text-align: left; padding: 5pt; border: 0.5pt solid #b7bec9; }
td { padding: 5pt; border: 0.5pt solid #b7bec9; vertical-align: top; }
.r { text-align: right; }
.parties td { width: 50%; }
.totals { width: 60%; margin-left: 40%; }
.totals td { border: none; }
.grand td { font-weight: bold; }
p { margin: 0 0 3pt 0; }
.gap { height: 10pt; }
</style>
</head>
<body>
<h1>Tax Invoice / कर चलन</h1>
<p th:text="'Invoice ' + ${inv.number} + ' | ' + ${inv.date}">Invoice</p>
<div class="gap"></div>
<table class="parties">
<tr>
<td><p><b>From / विक्रेता</b></p><p th:text="${inv.seller.name}">seller</p><p th:text="${inv.seller.address}">addr</p><p th:text="'GSTIN ' + ${inv.seller.gstin}">gstin</p></td>
<td><p><b>Bill to / ग्राहक</b></p><p th:text="${inv.buyer.name}">buyer</p><p th:text="${inv.buyer.address}">addr</p><p th:text="'GSTIN ' + ${inv.buyer.gstin}">gstin</p></td>
</tr>
</table>
<div class="gap"></div>
<table>
<thead>
<tr><th style="width:55%">Item / तपशील</th><th class="r">Qty</th><th class="r">Rate</th><th class="r">Amount / रक्कम</th></tr>
</thead>
<tbody>
<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>
</tbody>
</table>
<div class="gap"></div>
<table class="totals">
<tr><td>Subtotal / उपबेरीज</td><td class="r" th:text="${money.inr(inv.subtotal)}">0</td></tr>
<tr><td>CGST 9% + SGST 9% / कर</td><td class="r" th:text="${money.inr(inv.gst)}">0</td></tr>
<tr class="grand"><td>Total / एकूण</td><td class="r" th:text="${money.inr(inv.total)}">0</td></tr>
</table>
<div class="gap"></div>
<p th:text="${inv.note}">note</p>
</body>
</html>
@@ -0,0 +1,114 @@
package com.ankurm.pdfinvoice;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.pdfparser.PDFStreamParser;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* A number behind "it looks right": a shaper replaces several code points with one glyph (a conjunct such as
* ज्ञ is ज + virama + ञ in the text, one glyph on the page). Count the glyph codes each engine writes into the
* page content streams for the probe invoice and compare them with the number of code points drawn.
* All fonts are Identity-H, so every string operand is 2 bytes per glyph.
*/
@SpringBootTest
class GlyphCountTest {
@Autowired
List<InvoiceWriter> writers;
private static int codePointsDrawn(Invoice inv) {
int n = 0;
List<String> strings = new ArrayList<>();
strings.add("Tax Invoice / कर चलन");
strings.add("Invoice " + inv.number() + " | " + inv.date());
strings.add("From / विक्रेता");
strings.add(inv.seller().name());
strings.add(inv.seller().address());
strings.add("GSTIN " + inv.seller().gstin());
strings.add("Bill to / ग्राहक");
strings.add(inv.buyer().name());
strings.add(inv.buyer().address());
strings.add("GSTIN " + inv.buyer().gstin());
strings.add("Item / तपशील");
strings.add("Qty");
strings.add("Rate");
strings.add("Amount / रक्कम");
for (Invoice.Line l : inv.lines()) {
strings.add(l.description());
strings.add(String.valueOf(l.quantity()));
strings.add(Money.inr(l.unitPrice()));
strings.add(Money.inr(l.amount()));
}
strings.add("Subtotal / उपबेरीज");
strings.add(Money.inr(inv.subtotal()));
strings.add("CGST 9% + SGST 9% / कर");
strings.add(Money.inr(inv.gst()));
strings.add("Total / एकूण");
strings.add(Money.inr(inv.total()));
strings.add(inv.note());
for (String s : strings) {
n += s.replace(" ", "").codePointCount(0, s.replace(" ", "").length());
}
return n;
}
/** Every glyph code shown by Tj/TJ on page 1. Spaces are glyphs too, so only compare engines with each other. */
private static int glyphCodesShown(byte[] pdf) throws IOException {
int bytes = 0;
try (PDDocument doc = Loader.loadPDF(pdf)) {
PDFStreamParser parser = new PDFStreamParser(doc.getPage(0));
for (Object tok : parser.parse()) {
if (tok instanceof COSString s) {
bytes += s.getBytes().length;
} else if (tok instanceof COSArray a) {
for (COSBase b : a) {
if (b instanceof COSString s) {
bytes += s.getBytes().length;
}
}
}
}
}
return bytes / 2;
}
@Test
void shapedEnginesWriteFewerGlyphsThanCodePoints() throws Exception {
Invoice probe = SampleInvoices.probe("INV-PROBE");
List<String> log = new ArrayList<>();
log.add("# glyph codes written into page 1 content streams for the probe invoice (Identity-H, 2 bytes each)");
log.add("# code points drawn, spaces excluded: " + codePointsDrawn(probe));
log.add("# the engines also draw the spaces as glyphs, so read the numbers against each other, not against this one");
int unshaped = -1;
for (String engine : new String[] {"openpdf", "itext", "flyingsaucer", "openpdf-jdk", "openpdf-jdk-actualtext", "flyingsaucer-shaped"}) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
writers.stream().filter(w -> w.engine().equals(engine)).findFirst().orElseThrow().write(probe, out);
int glyphs = glyphCodesShown(out.toByteArray());
log.add(String.format("%-24s glyph codes: %d", engine, glyphs));
if (engine.equals("openpdf")) {
unshaped = glyphs;
}
if (engine.equals("openpdf-jdk")) {
assertThat(glyphs).as("a shaper merges code points").isLessThan(unshaped);
}
}
Path outFile = Path.of("output/04-glyph-counts.txt");
Files.createDirectories(outFile.getParent());
Files.writeString(outFile, String.join("\n", log) + "\n");
}
}
@@ -0,0 +1,66 @@
package com.ankurm.pdfinvoice;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.openpdf.text.pdf.LayoutProcessor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* OpenPDF's old static {@code LayoutProcessor} is a JVM-wide switch. This test turns it on the way the
* shaped Flying Saucer variant does and records what every other OpenPDF writer in the same JVM then does.
*/
@SpringBootTest
class LayoutProcessorTrapTest {
@Autowired
List<InvoiceWriter> writers;
private InvoiceWriter writer(String engine) {
return writers.stream().filter(w -> w.engine().equals(engine)).findFirst().orElseThrow();
}
@Test
void enablingTheStaticLayoutProcessorBreaksTheOtherOpenPdfWritersInTheSameJvm() throws IOException {
List<String> log = new ArrayList<>();
LayoutProcessor.enable();
try {
for (String engine : new String[] {"openpdf", "openpdf-jdk"}) {
try {
writer(engine).write(SampleInvoices.sample("INV-1"), new ByteArrayOutputStream());
log.add(engine + ": rendered normally");
} catch (Exception e) {
Throwable root = e;
while (root.getCause() != null) {
root = root.getCause();
}
log.add(engine + ": " + e.getClass().getSimpleName() + ": " + e.getMessage()
+ " | root cause " + root.getClass().getSimpleName() + ": " + root.getMessage());
assertThat(e).isNotNull();
}
}
} finally {
LayoutProcessor.disable();
}
try {
writer("openpdf-jdk").write(SampleInvoices.sample("INV-2"), new ByteArrayOutputStream());
log.add("after LayoutProcessor.disable(): openpdf-jdk rendered normally");
} catch (Exception e) {
log.add("after LayoutProcessor.disable(): openpdf-jdk FAILED " + e);
}
Path out = Path.of("output/05-layoutprocessor-trap.txt");
Files.createDirectories(out.getParent());
Files.writeString(out, "# LayoutProcessor.enable() in the same JVM, then two other OpenPDF writers, then disable()\n"
+ String.join("\n", log) + "\n");
assertThat(log.get(1)).contains("Don't use the deprecated LayoutProcessor");
assertThat(log.get(2)).contains("rendered normally");
}
}
@@ -0,0 +1,155 @@
package com.ankurm.pdfinvoice;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Asserts what is inside the PDFs by reading them back with Apache PDFBox. Everything printed here is
* what output/01-text-extraction.txt contains: a rerun of this class rewrites that file.
*/
@SpringBootTest
class PdfContentTest {
private static final List<String> LOG = new ArrayList<>();
private static final String[] DEVANAGARI = {
"कर चलन", "विक्रेता", "ग्राहक", "श्रीमती क्षमा कुलकर्णी", "ज्ञानेश्वरी – संपूर्ण ग्रंथ (मराठी)",
"प्रोग्रामिंग की कला (हिन्दी)", "विद्वान कर्मचारी मार्गदर्शिका", "कृपया धन्यवाद"};
@Autowired
List<InvoiceWriter> writers;
private Map<String, InvoiceWriter> byName() {
return writers.stream().collect(Collectors.toMap(InvoiceWriter::engine, Function.identity()));
}
private byte[] render(String engine) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byWriter(engine).write(SampleInvoices.sample("INV-2026-0042"), out);
return out.toByteArray();
}
private InvoiceWriter byWriter(String engine) {
return byName().get(engine);
}
private static String text(byte[] pdf) throws IOException {
try (PDDocument doc = Loader.loadPDF(pdf)) {
PDFTextStripper s = new PDFTextStripper();
s.setSortByPosition(true);
return s.getText(doc);
}
}
@ParameterizedTest
@ValueSource(strings = {"openpdf", "itext", "flyingsaucer"})
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);
}
/**
* Unshaped libraries write one glyph per code point, so the extracted text is the logical Marathi/Hindi
* exactly as typed, even though the page shows broken conjuncts. Extraction passing says nothing about shaping.
*/
@ParameterizedTest
@ValueSource(strings = {"openpdf", "itext", "flyingsaucer"})
void unshapedTextExtractsPerfectly(String engine) throws Exception {
String t = text(render(engine));
for (String s : DEVANAGARI) {
assertThat(t).as("%s should contain %s", engine, s).contains(s);
}
}
/**
* The trap on the other side: a correctly drawn page whose text layer is wrong. Glyphs that came out of
* the shaper have no one-to-one mapping back to code points, so copy/search/accessibility lose characters.
*/
@Test
void shapedWithoutActualTextDrawsRightButExtractsWrong() throws Exception {
String t = text(render("openpdf-jdk"));
assertThat(t).doesNotContain("विक्रेता", "ग्राहक", "श्रीमती क्षमा कुलकर्णी");
assertThat(t).contains("INV-2026-0042", "₹ 4,099.91");
log("openpdf-jdk", render("openpdf-jdk").length, t);
}
@Test
void actualTextRestoresExtractionOfTheShapedPage() throws Exception {
byte[] pdf = render("openpdf-jdk-actualtext");
String t = text(pdf);
for (String s : DEVANAGARI) {
assertThat(t).as("should contain %s", s).contains(s);
}
log("openpdf-jdk-actualtext", pdf.length, t);
}
@Test
void shapedFlyingSaucerAlsoLosesTheTextLayer() throws Exception {
byte[] pdf = render("flyingsaucer-shaped");
String t = text(pdf);
assertThat(t).contains("INV-2026-0042", "₹ 4,099.91");
assertThat(t).doesNotContain("विक्रेता");
log("flyingsaucer-shaped", pdf.length, t);
}
@ParameterizedTest
@ValueSource(strings = {"openpdf", "itext", "flyingsaucer", "openpdf-jdk-actualtext", "flyingsaucer-shaped"})
void theFontIsEmbeddedAsASubset(String engine) throws Exception {
byte[] pdf = render(engine);
List<String> fonts = new ArrayList<>();
try (PDDocument doc = Loader.loadPDF(pdf)) {
PDResources res = doc.getPage(0).getResources();
for (var name : res.getFontNames()) {
PDFont f = res.getFont(name);
fonts.add(f.getName() + " embedded=" + f.isEmbedded());
assertThat(f.isEmbedded()).as("%s %s", engine, f.getName()).isTrue();
assertThat(f.getName()).as("subset tag").matches("[A-Z]{6}\\+.*Noto.*");
}
}
assertThat(fonts).isNotEmpty();
// two Noto instances, 218 KB each on disk: a subset PDF is a small fraction of 436 KB
assertThat(pdf.length).as("%s subset PDF size", engine).isLessThan(60_000);
LOG.add(String.format("fonts %-24s %s", engine, fonts));
}
private static void log(String engine, int bytes, String text) {
LOG.add("=== " + engine + " (" + bytes + " bytes) ===\n" + text.strip());
}
@org.junit.jupiter.api.AfterAll
static void writeTranscript() throws IOException {
Path out = Path.of("output/01-text-extraction.txt");
Files.createDirectories(out.getParent());
List<String> sorted = new ArrayList<>(LOG);
sorted.sort(String::compareTo);
Files.writeString(out, "# PDFBox 3.0.8 text extraction of each engine's invoice, written by PdfContentTest\n\n"
+ String.join("\n\n", sorted) + "\n");
}
}