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
@@ -9,6 +9,7 @@ files.
|
|||||||
|
|
||||||
| Directory | Article | What it demonstrates |
|
| 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 |
|
| [`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 |
|
| [`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 |
|
| [`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 |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -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>
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||